Skip to content

Commit bbfed60

Browse files
committed
feat(okr,whiteboard): emit typed error envelopes across both domains
The okr and whiteboard commands now report every failure as a typed error envelope. Invalid flags, malformed input, output-file conflicts, and API or transport failures alike carry a stable category, subtype, the offending flag or Lark error code, and a meaningful exit code — so scripts and agents can branch on the error shape instead of scraping message strings.
1 parent 2bbab4d commit bbfed60

17 files changed

Lines changed: 422 additions & 224 deletions

.golangci.yml

Lines changed: 5 additions & 5 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/okr/|shortcuts/whiteboard/)
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/okr/|shortcuts/whiteboard/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
7676
text: errs-no-bare-wrap
7777
linters:
7878
- forbidigo
79-
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
80-
# still used by other domains until their later migration phase.
81-
- path-except: (shortcuts/drive/)
79+
# errs-no-legacy-helper bans shared helpers that emit legacy output.Err*
80+
# shapes, on domains that have migrated to typed errs.* builders.
81+
- path-except: (shortcuts/drive/|shortcuts/okr/|shortcuts/whiteboard/)
8282
text: errs-no-legacy-helper
8383
linters:
8484
- forbidigo

shortcuts/okr/okr_cycle_detail.go

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import (
1111
"strconv"
1212
"time"
1313

14+
"github.com/larksuite/cli/errs"
1415
"github.com/larksuite/cli/shortcuts/common"
15-
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
1616
)
1717

1818
// OKRCycleDetail lists all objectives and their key results under a given OKR cycle.
@@ -30,10 +30,10 @@ var OKRCycleDetail = common.Shortcut{
3030
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
3131
cycleID := runtime.Str("cycle-id")
3232
if cycleID == "" {
33-
return common.FlagErrorf("--cycle-id is required")
33+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required").WithParam("--cycle-id")
3434
}
3535
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
36-
return common.FlagErrorf("--cycle-id must be a positive int64")
36+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
3737
}
3838
return nil
3939
},
@@ -52,8 +52,7 @@ var OKRCycleDetail = common.Shortcut{
5252
cycleID := runtime.Str("cycle-id")
5353

5454
// Paginate objectives under the cycle.
55-
queryParams := make(larkcore.QueryParams)
56-
queryParams.Set("page_size", "100")
55+
queryParams := map[string]interface{}{"page_size": "100"}
5756

5857
var objectives []Objective
5958
page := 0
@@ -71,7 +70,7 @@ var OKRCycleDetail = common.Shortcut{
7170
page++
7271

7372
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
74-
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
73+
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
7574
if err != nil {
7675
return err
7776
}
@@ -93,7 +92,7 @@ var OKRCycleDetail = common.Shortcut{
9392
if !hasMore || pageToken == "" {
9493
break
9594
}
96-
queryParams.Set("page_token", pageToken)
95+
queryParams["page_token"] = pageToken
9796
}
9897

9998
// For each objective, paginate key results and convert to response format.
@@ -104,8 +103,7 @@ var OKRCycleDetail = common.Shortcut{
104103
}
105104
obj := &objectives[i]
106105

107-
krQuery := make(larkcore.QueryParams)
108-
krQuery.Set("page_size", "100")
106+
krQuery := map[string]interface{}{"page_size": "100"}
109107

110108
var keyResults []KeyResult
111109
krPage := 0
@@ -123,7 +121,7 @@ var OKRCycleDetail = common.Shortcut{
123121
krPage++
124122

125123
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
126-
data, err := runtime.DoAPIJSON("GET", path, krQuery, nil)
124+
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
127125
if err != nil {
128126
return err
129127
}
@@ -145,7 +143,7 @@ var OKRCycleDetail = common.Shortcut{
145143
if !hasMore || pageToken == "" {
146144
break
147145
}
148-
krQuery.Set("page_token", pageToken)
146+
krQuery["page_token"] = pageToken
149147
}
150148

151149
respObj := obj.ToResp()

shortcuts/okr/okr_cycle_detail_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ package okr
66
import (
77
"bytes"
88
"encoding/json"
9+
"errors"
910
"strings"
1011
"testing"
1112

1213
"github.com/spf13/cobra"
1314

15+
"github.com/larksuite/cli/errs"
1416
"github.com/larksuite/cli/internal/cmdutil"
1517
"github.com/larksuite/cli/internal/core"
1618
"github.com/larksuite/cli/internal/httpmock"
@@ -106,6 +108,31 @@ func TestCycleDetailValidate_InvalidCycleID_Negative(t *testing.T) {
106108
}
107109
}
108110

111+
// TestCycleDetailValidate_TypedError locks the typed-envelope contract shared by
112+
// every okr flag check: an invalid flag surfaces as *errs.ValidationError carrying
113+
// SubtypeInvalidArgument and the offending --flag (readable via errors.As /
114+
// errs.ProblemOf), and maps to the validation exit code rather than a legacy api error.
115+
func TestCycleDetailValidate_TypedError(t *testing.T) {
116+
t.Parallel()
117+
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
118+
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "0"})
119+
120+
var ve *errs.ValidationError
121+
if !errors.As(err, &ve) {
122+
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
123+
}
124+
if ve.Subtype != errs.SubtypeInvalidArgument {
125+
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
126+
}
127+
if ve.Param != "--cycle-id" {
128+
t.Errorf("Param = %q, want %q", ve.Param, "--cycle-id")
129+
}
130+
p, ok := errs.ProblemOf(err)
131+
if !ok || p.Category != errs.CategoryValidation {
132+
t.Errorf("ProblemOf category = %v (ok=%v), want %q", p, ok, errs.CategoryValidation)
133+
}
134+
}
135+
109136
func TestCycleDetailValidate_ValidCycleID(t *testing.T) {
110137
t.Parallel()
111138
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))

shortcuts/okr/okr_cycle_list.go

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,30 +12,30 @@ import (
1212
"strings"
1313
"time"
1414

15+
"github.com/larksuite/cli/errs"
1516
"github.com/larksuite/cli/internal/validate"
1617
"github.com/larksuite/cli/shortcuts/common"
17-
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
1818
)
1919

2020
// parseTimeRange parses a "YYYY-MM--YYYY-MM" string into two time.Time values.
2121
// The start is the first moment of the start month; the end is the last moment of the end month.
2222
func parseTimeRange(s string) (start, end time.Time, err error) {
2323
parts := strings.SplitN(s, "--", 2)
2424
if len(parts) != 2 {
25-
return time.Time{}, time.Time{}, fmt.Errorf("invalid time-range format %q, expected YYYY-MM--YYYY-MM", s)
25+
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range format %q, expected YYYY-MM--YYYY-MM", s).WithParam("--time-range")
2626
}
2727
start, err = time.Parse("2006-01", strings.TrimSpace(parts[0]))
2828
if err != nil {
29-
return time.Time{}, time.Time{}, fmt.Errorf("invalid start month %q: %w", parts[0], err)
29+
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range start month %q: %v", parts[0], err).WithParam("--time-range").WithCause(err)
3030
}
3131
end, err = time.Parse("2006-01", strings.TrimSpace(parts[1]))
3232
if err != nil {
33-
return time.Time{}, time.Time{}, fmt.Errorf("invalid end month %q: %w", parts[1], err)
33+
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range end month %q: %v", parts[1], err).WithParam("--time-range").WithCause(err)
3434
}
3535
// end is the last moment of the end month
3636
end = end.AddDate(0, 1, 0).Add(-time.Millisecond)
3737
if start.After(end) {
38-
return time.Time{}, time.Time{}, fmt.Errorf("start month %s is after end month %s", parts[0], parts[1])
38+
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range: start month %s is after end month %s", parts[0], parts[1]).WithParam("--time-range")
3939
}
4040
return start, end, nil
4141
}
@@ -69,7 +69,7 @@ var OKRListCycles = common.Shortcut{
6969
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
7070
idType := runtime.Str("user-id-type")
7171
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
72-
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
72+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
7373
}
7474
userID := runtime.Str("user-id")
7575
if err := validate.RejectControlChars(userID, "user-id"); err != nil {
@@ -82,7 +82,7 @@ var OKRListCycles = common.Shortcut{
8282
return err
8383
}
8484
if _, _, err := parseTimeRange(tr); err != nil {
85-
return common.FlagErrorf("--time-range: %s", err)
85+
return err
8686
}
8787
}
8888
return nil
@@ -110,16 +110,17 @@ var OKRListCycles = common.Shortcut{
110110
var err error
111111
rangeStart, rangeEnd, err = parseTimeRange(timeRange)
112112
if err != nil {
113-
return common.FlagErrorf("--time-range: %s", err)
113+
return err
114114
}
115115
hasRange = true
116116
}
117117

118118
// Paginated fetch of all cycles
119-
queryParams := make(larkcore.QueryParams)
120-
queryParams.Set("user_id", userID)
121-
queryParams.Set("user_id_type", userIDType)
122-
queryParams.Set("page_size", "100")
119+
queryParams := map[string]interface{}{
120+
"user_id": userID,
121+
"user_id_type": userIDType,
122+
"page_size": "100",
123+
}
123124

124125
var allCycles []Cycle
125126
page := 0
@@ -136,7 +137,7 @@ var OKRListCycles = common.Shortcut{
136137
}
137138
page++
138139

139-
data, err := runtime.DoAPIJSON("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
140+
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
140141
if err != nil {
141142
return err
142143
}
@@ -158,7 +159,7 @@ var OKRListCycles = common.Shortcut{
158159
if !hasMore || pageToken == "" {
159160
break
160161
}
161-
queryParams.Set("page_token", pageToken)
162+
queryParams["page_token"] = pageToken
162163
}
163164

164165
// Filter by time-range overlap

shortcuts/okr/okr_errors.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package okr
5+
6+
import (
7+
"errors"
8+
9+
"github.com/larksuite/cli/errs"
10+
"github.com/larksuite/cli/extension/fileio"
11+
)
12+
13+
// okrInputStatError maps a FileIO.Stat/Open error for input file validation to
14+
// a typed validation error: path validation failures read as "unsafe file
15+
// path", other errors as "cannot read file".
16+
func okrInputStatError(err error) error {
17+
if err == nil {
18+
return nil
19+
}
20+
if errors.Is(err, fileio.ErrPathValidation) {
21+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
22+
}
23+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
24+
}
25+
26+
// wrapOkrNetworkErr returns err unchanged when it is already a typed errs.*
27+
// error (preserving subtype / code / log_id from the runtime boundary) and only
28+
// wraps a raw, unclassified error as a transport-level network error.
29+
func wrapOkrNetworkErr(err error, format string, args ...any) error {
30+
if _, ok := errs.ProblemOf(err); ok {
31+
return err
32+
}
33+
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
34+
}

shortcuts/okr/okr_image_upload.go

Lines changed: 15 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ package okr
55

66
import (
77
"context"
8-
"encoding/json"
98
"errors"
109
"fmt"
1110
"path/filepath"
@@ -14,6 +13,7 @@ import (
1413

1514
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
1615

16+
"github.com/larksuite/cli/errs"
1717
"github.com/larksuite/cli/internal/output"
1818
"github.com/larksuite/cli/shortcuts/common"
1919
)
@@ -43,24 +43,24 @@ var OKRUploadImage = common.Shortcut{
4343
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
4444
filePath := runtime.Str("file")
4545
if filePath == "" {
46-
return common.FlagErrorf("--file is required")
46+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
4747
}
4848
ext := strings.ToLower(filepath.Ext(filePath))
4949
if !allowedImageExts[ext] {
50-
return common.FlagErrorf("--file must be an image (supported: JPG, JPEG, PNG, GIF, BMP), got %q", ext)
50+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be an image (supported: JPG, JPEG, PNG, GIF, BMP), got %q", ext).WithParam("--file")
5151
}
5252

5353
targetID := runtime.Str("target-id")
5454
if targetID == "" {
55-
return common.FlagErrorf("--target-id is required")
55+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
5656
}
5757
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
58-
return common.FlagErrorf("--target-id must be a positive int64")
58+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
5959
}
6060

6161
targetType := runtime.Str("target-type")
6262
if _, ok := targetTypeAllowed[targetType]; !ok {
63-
return common.FlagErrorf("--target-type must be one of: objective | key_result")
63+
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-type must be one of: objective | key_result").WithParam("--target-type")
6464
}
6565
return nil
6666
},
@@ -87,12 +87,12 @@ var OKRUploadImage = common.Shortcut{
8787

8888
info, err := runtime.FileIO().Stat(filePath)
8989
if err != nil {
90-
return common.WrapInputStatError(err)
90+
return okrInputStatError(err)
9191
}
9292

9393
f, err := runtime.FileIO().Open(filePath)
9494
if err != nil {
95-
return common.WrapInputStatError(err)
95+
return okrInputStatError(err)
9696
}
9797
defer f.Close()
9898

@@ -114,26 +114,19 @@ var OKRUploadImage = common.Shortcut{
114114
if errors.As(err, &exitErr) {
115115
return err
116116
}
117-
return output.ErrNetwork("upload failed: %v", err)
117+
return wrapOkrNetworkErr(err, "upload failed: %v", err)
118118
}
119119

120-
var result map[string]interface{}
121-
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
122-
return output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
123-
}
124-
125-
if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 {
126-
msg, _ := result["msg"].(string)
127-
return output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"])
120+
data, err := runtime.ClassifyAPIResponse(apiResp)
121+
if err != nil {
122+
return err
128123
}
129124

130-
data, _ := result["data"].(map[string]interface{})
131-
fileToken, _ := data["file_token"].(string)
132-
url, _ := data["url"].(string)
133-
125+
fileToken := common.GetString(data, "file_token")
134126
if fileToken == "" {
135-
return output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
127+
return errs.NewInternalError(errs.SubtypeInvalidResponse, "upload failed: no file_token returned")
136128
}
129+
url := common.GetString(data, "url")
137130

138131
runtime.Out(map[string]interface{}{
139132
"file_token": fileToken,

0 commit comments

Comments
 (0)