Skip to content

Commit cb640a2

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 3990151 commit cb640a2

21 files changed

Lines changed: 767 additions & 248 deletions

.golangci.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,20 +73,20 @@ linters:
7373
- forbidigo
7474
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
7575
# Add a path when its migration is complete.
76-
- 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/mail/|shortcuts/base/)
76+
- 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/mail/|shortcuts/base/|shortcuts/okr/|shortcuts/whiteboard/)
7777
text: errs-typed-only
7878
linters:
7979
- forbidigo
8080
# errs-no-bare-wrap enforced on paths fully migrated to typed final
8181
# errors. Scoped separately from errs-typed-only because cmd/auth/,
8282
# cmd/config/ still have residual fmt.Errorf and must not be caught.
83-
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/base/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
83+
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/base/|shortcuts/okr/|shortcuts/whiteboard/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
8484
text: errs-no-bare-wrap
8585
linters:
8686
- forbidigo
8787
# errs-no-legacy-helper is scoped to migrated domains: the shared helpers
8888
# it bans are still used by other domains until their later migration phase.
89-
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/base/)
89+
- path-except: (shortcuts/drive/|shortcuts/mail/|shortcuts/base/|shortcuts/okr/|shortcuts/whiteboard/)
9090
text: errs-no-legacy-helper
9191
linters:
9292
- forbidigo

shortcuts/common/runner.go

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -603,27 +603,6 @@ func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
603603
return resolved, nil
604604
}
605605

606-
// WrapSaveError matches a FileIO.Save error against known categories and wraps
607-
// it with the caller-provided message prefix, preserving backward-compatible
608-
// error text per shortcut.
609-
func WrapSaveError(err error, pathMsg, mkdirMsg, writeMsg string) error {
610-
if err == nil {
611-
return nil
612-
}
613-
var me *fileio.MkdirError
614-
var we *fileio.WriteError
615-
switch {
616-
case errors.Is(err, fileio.ErrPathValidation):
617-
return fmt.Errorf("%s: %w", pathMsg, err)
618-
case errors.As(err, &me):
619-
return fmt.Errorf("%s: %w", mkdirMsg, err)
620-
case errors.As(err, &we):
621-
return fmt.Errorf("%s: %w", writeMsg, err)
622-
default:
623-
return fmt.Errorf("%s: %w", writeMsg, err)
624-
}
625-
}
626-
627606
// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
628607
// caller-provided message prefix.
629608
func WrapOpenError(err error, pathMsg, readMsg string) error {

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_errors_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
2+
// SPDX-License-Identifier: MIT
3+
4+
package okr
5+
6+
import (
7+
"errors"
8+
"testing"
9+
10+
"github.com/larksuite/cli/errs"
11+
"github.com/larksuite/cli/extension/fileio"
12+
)
13+
14+
func TestOkrInputStatError(t *testing.T) {
15+
if okrInputStatError(nil) != nil {
16+
t.Fatal("nil error should map to nil")
17+
}
18+
19+
var ve *errs.ValidationError
20+
21+
pathCause := errors.New("traversal")
22+
pathErr := okrInputStatError(&fileio.PathValidationError{Err: pathCause})
23+
if !errors.As(pathErr, &ve) || ve.Subtype != errs.SubtypeInvalidArgument {
24+
t.Fatalf("path validation: got %T (%v)", pathErr, pathErr)
25+
}
26+
if !errors.Is(pathErr, fileio.ErrPathValidation) || !errors.Is(pathErr, pathCause) {
27+
t.Fatal("path validation cause should be retained")
28+
}
29+
30+
genericCause := errors.New("permission denied")
31+
genericErr := okrInputStatError(genericCause)
32+
if !errors.As(genericErr, &ve) || ve.Subtype != errs.SubtypeInvalidArgument {
33+
t.Fatalf("generic: got %T (%v)", genericErr, genericErr)
34+
}
35+
if !errors.Is(genericErr, genericCause) {
36+
t.Fatal("generic cause should be retained")
37+
}
38+
}
39+
40+
func TestWrapOkrNetworkErr(t *testing.T) {
41+
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "already typed")
42+
if got := wrapOkrNetworkErr(typed, "wrap %v", typed); got != error(typed) {
43+
t.Fatalf("typed error must pass through unchanged, got %v", got)
44+
}
45+
46+
raw := errors.New("dial tcp: i/o timeout")
47+
got := wrapOkrNetworkErr(raw, "upload failed: %v", raw)
48+
var ne *errs.NetworkError
49+
if !errors.As(got, &ne) || ne.Subtype != errs.SubtypeNetworkTransport {
50+
t.Fatalf("raw error: got %T (%v)", got, got)
51+
}
52+
if !errors.Is(got, raw) {
53+
t.Fatal("raw cause should be retained via WithCause")
54+
}
55+
}

0 commit comments

Comments
 (0)