Skip to content

Commit c42cfe9

Browse files
committed
feat(task): emit typed error envelopes across the task domain
Task commands now return structured, typed errors instead of the legacy exit-code envelope: every failure carries a stable category, subtype, and recovery hint, so callers can branch on the error class instead of parsing messages. Exit codes derive from the error category — input validation exits 2, a permission denial exits 3, other API errors exit 1. Batch operations (adding tasks to a tasklist, creating a tasklist with tasks) now report partial failure honestly: the per-item successes and failures stay on stdout and the command exits non-zero instead of masking failures as a success.
1 parent 98173ae commit c42cfe9

37 files changed

Lines changed: 1561 additions & 759 deletions

.golangci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,20 @@ linters:
6565
- forbidigo
6666
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
6767
# Add a path when its migration is complete.
68-
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/)
68+
- path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/calendar/helpers\.go|shortcuts/drive/|shortcuts/task/)
6969
text: errs-typed-only
7070
linters:
7171
- forbidigo
7272
# errs-no-bare-wrap enforced on paths fully migrated to typed final
7373
# errors. Scoped separately from errs-typed-only because cmd/auth/,
7474
# cmd/config/ still have residual fmt.Errorf and must not be caught.
75-
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
75+
- path-except: (shortcuts/drive/|shortcuts/task/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
7676
text: errs-no-bare-wrap
7777
linters:
7878
- forbidigo
7979
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
8080
# still used by other domains until their later migration phase.
81-
- path-except: (shortcuts/drive/)
81+
- path-except: (shortcuts/drive/|shortcuts/task/)
8282
text: errs-no-legacy-helper
8383
linters:
8484
- forbidigo

shortcuts/task/shortcuts.go

Lines changed: 7 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,8 @@ import (
1313
"strings"
1414
"time"
1515

16-
"github.com/larksuite/cli/internal/output"
16+
"github.com/larksuite/cli/errs"
1717
"github.com/larksuite/cli/shortcuts/common"
18-
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
1918
)
2019

2120
func inferTaskMemberType(id string) string {
@@ -107,7 +106,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
107106
// Handle generic JSON payload if provided
108107
if dataStr := runtime.Str("data"); dataStr != "" {
109108
if err := json.Unmarshal([]byte(dataStr), &body); err != nil {
110-
return nil, output.ErrValidation("--data must be a valid JSON object: %v", err)
109+
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--data must be a valid JSON object: %v", err).WithParam("--data")
111110
}
112111
}
113112

@@ -143,7 +142,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
143142
if dueStr := runtime.Str("due"); dueStr != "" {
144143
dueObj, err := parseTaskTime(dueStr)
145144
if err != nil {
146-
return nil, output.ErrValidation("failed to parse due time: %v", err)
145+
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "failed to parse due time: %v", err).WithParam("--due")
147146
}
148147
body["due"] = dueObj
149148
}
@@ -154,7 +153,7 @@ func buildTaskCreateBody(runtime *common.RuntimeContext) (map[string]interface{}
154153

155154
summary, _ := body["summary"].(string)
156155
if strings.TrimSpace(summary) == "" {
157-
return nil, output.ErrValidation("task summary is required")
156+
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "task summary is required").WithParam("--summary")
158157
}
159158

160159
return body, nil
@@ -194,27 +193,11 @@ var CreateTask = common.Shortcut{
194193
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
195194
body, err := buildTaskCreateBody(runtime)
196195
if err != nil {
197-
return WrapTaskError(ErrCodeTaskInvalidParams, err.Error(), "create task")
198-
}
199-
200-
queryParams := make(larkcore.QueryParams)
201-
queryParams.Set("user_id_type", "open_id")
202-
203-
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
204-
HttpMethod: http.MethodPost,
205-
ApiPath: "/open-apis/task/v2/tasks",
206-
QueryParams: queryParams,
207-
Body: body,
208-
})
209-
210-
var result map[string]interface{}
211-
if err == nil {
212-
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
213-
return output.Errorf(output.ExitAPI, "api_error", "failed to parse response: %v", parseErr)
214-
}
196+
return err
215197
}
216198

217-
data, err := HandleTaskApiResult(result, err, "create task")
199+
params := map[string]interface{}{"user_id_type": "open_id"}
200+
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks", params, body)
218201
if err != nil {
219202
return err
220203
}

shortcuts/task/task_assign.go

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,13 @@ package task
55

66
import (
77
"context"
8-
"encoding/json"
98
"fmt"
109
"io"
1110
"net/http"
1211
"net/url"
1312
"strings"
1413

15-
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
16-
14+
"github.com/larksuite/cli/errs"
1715
"github.com/larksuite/cli/shortcuts/common"
1816
)
1917

@@ -35,7 +33,7 @@ var AssignTask = common.Shortcut{
3533

3634
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
3735
if runtime.Str("add") == "" && runtime.Str("remove") == "" {
38-
return WrapTaskError(ErrCodeTaskInvalidParams, "must specify either --add or --remove", "validate assign")
36+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "must specify either --add or --remove")
3937
}
4038
return nil
4139
},
@@ -62,28 +60,13 @@ var AssignTask = common.Shortcut{
6260

6361
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
6462
taskId := url.PathEscape(runtime.Str("task-id"))
65-
queryParams := make(larkcore.QueryParams)
66-
queryParams.Set("user_id_type", "open_id")
63+
params := map[string]interface{}{"user_id_type": "open_id"}
6764

6865
var lastData map[string]interface{}
6966

7067
if addStr := runtime.Str("add"); addStr != "" {
7168
body := buildMembersBody(addStr, "assignee", runtime.Str("idempotency-key"))
72-
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
73-
HttpMethod: http.MethodPost,
74-
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/add_members",
75-
QueryParams: queryParams,
76-
Body: body,
77-
})
78-
79-
var result map[string]interface{}
80-
if err == nil {
81-
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
82-
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse add members")
83-
}
84-
}
85-
86-
data, err := HandleTaskApiResult(result, err, "add task members")
69+
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/add_members", params, body)
8770
if err != nil {
8871
return err
8972
}
@@ -92,21 +75,7 @@ var AssignTask = common.Shortcut{
9275

9376
if removeStr := runtime.Str("remove"); removeStr != "" {
9477
body := buildMembersBody(removeStr, "assignee", "")
95-
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
96-
HttpMethod: http.MethodPost,
97-
ApiPath: "/open-apis/task/v2/tasks/" + taskId + "/remove_members",
98-
QueryParams: queryParams,
99-
Body: body,
100-
})
101-
102-
var result map[string]interface{}
103-
if err == nil {
104-
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
105-
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse remove members")
106-
}
107-
}
108-
109-
data, err := HandleTaskApiResult(result, err, "remove task members")
78+
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/"+taskId+"/remove_members", params, body)
11079
if err != nil {
11180
return err
11281
}

shortcuts/task/task_assign_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,100 @@
44
package task
55

66
import (
7+
"errors"
78
"testing"
89

910
"github.com/spf13/cobra"
1011

12+
"github.com/larksuite/cli/errs"
13+
"github.com/larksuite/cli/internal/httpmock"
14+
"github.com/larksuite/cli/internal/output"
1115
"github.com/larksuite/cli/shortcuts/common"
1216
"github.com/smartystreets/goconvey/convey"
1317
)
1418

19+
// TestAssignTask_RequiresAddOrRemove covers the Validate guard: neither --add
20+
// nor --remove yields a typed validation error (exit 2) before any API call.
21+
func TestAssignTask_RequiresAddOrRemove(t *testing.T) {
22+
f, stdout, _, _ := taskShortcutTestFactory(t)
23+
24+
s := AssignTask
25+
args := []string{"+assign", "--task-id", "task-1", "--as", "bot", "--format", "json"}
26+
err := runMountedTaskShortcut(t, s, args, f, stdout)
27+
28+
var ve *errs.ValidationError
29+
if !errors.As(err, &ve) {
30+
t.Fatalf("err = %T, want *errs.ValidationError; err = %v", err, err)
31+
}
32+
if ve.Subtype != errs.SubtypeInvalidArgument {
33+
t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
34+
}
35+
if got := output.ExitCodeOf(err); got != output.ExitValidation {
36+
t.Errorf("exit code = %d, want %d", got, output.ExitValidation)
37+
}
38+
}
39+
40+
// TestAssignTask_MalformedResponse covers the Execute parse-response arm: a
41+
// 200 with an unparseable body surfaces a typed internal invalid_response
42+
// error (exit 5).
43+
func TestAssignTask_MalformedResponse(t *testing.T) {
44+
f, stdout, _, reg := taskShortcutTestFactory(t)
45+
warmTenantToken(t, f, reg)
46+
47+
reg.Register(&httpmock.Stub{
48+
Method: "POST",
49+
URL: "/open-apis/task/v2/tasks/task-1/add_members",
50+
Status: 200,
51+
RawBody: []byte("{not-json"),
52+
})
53+
54+
s := AssignTask
55+
args := []string{"+assign", "--task-id", "task-1", "--add", "ou_user_1", "--as", "bot", "--format", "json"}
56+
err := runMountedTaskShortcut(t, s, args, f, stdout)
57+
58+
var ie *errs.InternalError
59+
if !errors.As(err, &ie) {
60+
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
61+
}
62+
if ie.Subtype != errs.SubtypeInvalidResponse {
63+
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
64+
}
65+
if got := output.ExitCodeOf(err); got != output.ExitInternal {
66+
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
67+
}
68+
}
69+
70+
// TestAssignTask_MalformedResponse_RemoveArm covers the Execute remove-members
71+
// parse arm: with only --remove set, the add arm is skipped and the
72+
// remove_members POST returns a 200 with an unparseable body, which must
73+
// surface a typed internal invalid_response error (exit 5).
74+
func TestAssignTask_MalformedResponse_RemoveArm(t *testing.T) {
75+
f, stdout, _, reg := taskShortcutTestFactory(t)
76+
warmTenantToken(t, f, reg)
77+
78+
reg.Register(&httpmock.Stub{
79+
Method: "POST",
80+
URL: "/open-apis/task/v2/tasks/task-1/remove_members",
81+
Status: 200,
82+
RawBody: []byte("{not-json"),
83+
})
84+
85+
s := AssignTask
86+
args := []string{"+assign", "--task-id", "task-1", "--remove", "ou_user_1", "--as", "bot", "--format", "json"}
87+
err := runMountedTaskShortcut(t, s, args, f, stdout)
88+
89+
var ie *errs.InternalError
90+
if !errors.As(err, &ie) {
91+
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
92+
}
93+
if ie.Subtype != errs.SubtypeInvalidResponse {
94+
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
95+
}
96+
if got := output.ExitCodeOf(err); got != output.ExitInternal {
97+
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
98+
}
99+
}
100+
15101
func TestBuildMembersBody(t *testing.T) {
16102
convey.Convey("Build with ids and token", t, func() {
17103
body := buildMembersBody("u1, u2 , ", "assignee", "token1")

0 commit comments

Comments
 (0)