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
10 changes: 5 additions & 5 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,20 +65,20 @@ linters:
- forbidigo
# errs-typed-only enforced on paths already migrated to errs.NewXxxError.
# Add a path when its migration is complete.
- 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/)
- 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/)
text: errs-typed-only
linters:
- forbidigo
# errs-no-bare-wrap enforced on paths fully migrated to typed final
# errors. Scoped separately from errs-typed-only because cmd/auth/,
# cmd/config/ still have residual fmt.Errorf and must not be caught.
- path-except: (shortcuts/drive/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
- path-except: (shortcuts/drive/|shortcuts/okr/|shortcuts/whiteboard/|shortcuts/calendar/helpers\.go|shortcuts/common/mcp_client\.go)
text: errs-no-bare-wrap
linters:
- forbidigo
# errs-no-legacy-helper is drive-only: the shared helpers it bans are
# still used by other domains until their later migration phase.
- path-except: (shortcuts/drive/)
# errs-no-legacy-helper bans shared helpers that emit legacy output.Err*
# shapes, on domains that have migrated to typed errs.* builders.
- path-except: (shortcuts/drive/|shortcuts/okr/|shortcuts/whiteboard/)
text: errs-no-legacy-helper
linters:
- forbidigo
Expand Down
21 changes: 0 additions & 21 deletions shortcuts/common/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -586,27 +586,6 @@ func (ctx *RuntimeContext) ResolveSavePath(path string) (string, error) {
return resolved, nil
}

// WrapSaveError matches a FileIO.Save error against known categories and wraps
// it with the caller-provided message prefix, preserving backward-compatible
// error text per shortcut.
func WrapSaveError(err error, pathMsg, mkdirMsg, writeMsg string) error {
if err == nil {
return nil
}
var me *fileio.MkdirError
var we *fileio.WriteError
switch {
case errors.Is(err, fileio.ErrPathValidation):
return fmt.Errorf("%s: %w", pathMsg, err)
case errors.As(err, &me):
return fmt.Errorf("%s: %w", mkdirMsg, err)
case errors.As(err, &we):
return fmt.Errorf("%s: %w", writeMsg, err)
default:
return fmt.Errorf("%s: %w", writeMsg, err)
}
}

// WrapOpenError matches a FileIO.Open/Stat error and wraps it with the
// caller-provided message prefix.
func WrapOpenError(err error, pathMsg, readMsg string) error {
Expand Down
20 changes: 9 additions & 11 deletions shortcuts/okr/okr_cycle_detail.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
"strconv"
"time"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)

// OKRCycleDetail lists all objectives and their key results under a given OKR cycle.
Expand All @@ -30,10 +30,10 @@
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
if cycleID == "" {
return common.FlagErrorf("--cycle-id is required")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required").WithParam("--cycle-id")

Check warning on line 33 in shortcuts/okr/okr_cycle_detail.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/okr/okr_cycle_detail.go#L33

Added line #L33 was not covered by tests
}
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return common.FlagErrorf("--cycle-id must be a positive int64")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
return nil
},
Expand All @@ -52,8 +52,7 @@
cycleID := runtime.Str("cycle-id")

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

var objectives []Objective
page := 0
Expand All @@ -71,7 +70,7 @@
page++

path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.DoAPIJSON("GET", path, queryParams, nil)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return err
}
Expand All @@ -93,7 +92,7 @@
if !hasMore || pageToken == "" {
break
}
queryParams.Set("page_token", pageToken)
queryParams["page_token"] = pageToken
}

// For each objective, paginate key results and convert to response format.
Expand All @@ -104,8 +103,7 @@
}
obj := &objectives[i]

krQuery := make(larkcore.QueryParams)
krQuery.Set("page_size", "100")
krQuery := map[string]interface{}{"page_size": "100"}

var keyResults []KeyResult
krPage := 0
Expand All @@ -123,7 +121,7 @@
krPage++

path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
data, err := runtime.DoAPIJSON("GET", path, krQuery, nil)
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
if err != nil {
return err
}
Expand All @@ -145,7 +143,7 @@
if !hasMore || pageToken == "" {
break
}
krQuery.Set("page_token", pageToken)
krQuery["page_token"] = pageToken
}

respObj := obj.ToResp()
Expand Down
27 changes: 27 additions & 0 deletions shortcuts/okr/okr_cycle_detail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ package okr
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"

"github.com/spf13/cobra"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
Expand Down Expand Up @@ -106,6 +108,31 @@ func TestCycleDetailValidate_InvalidCycleID_Negative(t *testing.T) {
}
}

// TestCycleDetailValidate_TypedError locks the typed-envelope contract shared by
// every okr flag check: an invalid flag surfaces as *errs.ValidationError carrying
// SubtypeInvalidArgument and the offending --flag (readable via errors.As /
// errs.ProblemOf), and maps to the validation exit code rather than a legacy api error.
func TestCycleDetailValidate_TypedError(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "0"})

var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
}
if ve.Param != "--cycle-id" {
t.Errorf("Param = %q, want %q", ve.Param, "--cycle-id")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("ProblemOf category = %v (ok=%v), want %q", p, ok, errs.CategoryValidation)
}
}

func TestCycleDetailValidate_ValidCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
Expand Down
29 changes: 15 additions & 14 deletions shortcuts/okr/okr_cycle_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,30 +12,30 @@
"strings"
"time"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)

// parseTimeRange parses a "YYYY-MM--YYYY-MM" string into two time.Time values.
// The start is the first moment of the start month; the end is the last moment of the end month.
func parseTimeRange(s string) (start, end time.Time, err error) {
parts := strings.SplitN(s, "--", 2)
if len(parts) != 2 {
return time.Time{}, time.Time{}, fmt.Errorf("invalid time-range format %q, expected YYYY-MM--YYYY-MM", s)
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range format %q, expected YYYY-MM--YYYY-MM", s).WithParam("--time-range")
}
start, err = time.Parse("2006-01", strings.TrimSpace(parts[0]))
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid start month %q: %w", parts[0], err)
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range start month %q: %v", parts[0], err).WithParam("--time-range").WithCause(err)

Check warning on line 29 in shortcuts/okr/okr_cycle_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/okr/okr_cycle_list.go#L29

Added line #L29 was not covered by tests
}
end, err = time.Parse("2006-01", strings.TrimSpace(parts[1]))
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid end month %q: %w", parts[1], err)
return time.Time{}, time.Time{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --time-range end month %q: %v", parts[1], err).WithParam("--time-range").WithCause(err)

Check warning on line 33 in shortcuts/okr/okr_cycle_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/okr/okr_cycle_list.go#L33

Added line #L33 was not covered by tests
}
// end is the last moment of the end month
end = end.AddDate(0, 1, 0).Add(-time.Millisecond)
if start.After(end) {
return time.Time{}, time.Time{}, fmt.Errorf("start month %s is after end month %s", parts[0], parts[1])
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")
}
return start, end, nil
}
Expand Down Expand Up @@ -69,7 +69,7 @@
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
userID := runtime.Str("user-id")
if err := validate.RejectControlChars(userID, "user-id"); err != nil {
Expand All @@ -82,7 +82,7 @@
return err
}
if _, _, err := parseTimeRange(tr); err != nil {
return common.FlagErrorf("--time-range: %s", err)
return err
}
}
return nil
Expand Down Expand Up @@ -110,16 +110,17 @@
var err error
rangeStart, rangeEnd, err = parseTimeRange(timeRange)
if err != nil {
return common.FlagErrorf("--time-range: %s", err)
return err

Check warning on line 113 in shortcuts/okr/okr_cycle_list.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/okr/okr_cycle_list.go#L113

Added line #L113 was not covered by tests
}
hasRange = true
}

// Paginated fetch of all cycles
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id", userID)
queryParams.Set("user_id_type", userIDType)
queryParams.Set("page_size", "100")
queryParams := map[string]interface{}{
"user_id": userID,
"user_id_type": userIDType,
"page_size": "100",
}

var allCycles []Cycle
page := 0
Expand All @@ -136,7 +137,7 @@
}
page++

data, err := runtime.DoAPIJSON("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
if err != nil {
return err
}
Expand All @@ -158,7 +159,7 @@
if !hasMore || pageToken == "" {
break
}
queryParams.Set("page_token", pageToken)
queryParams["page_token"] = pageToken
}

// Filter by time-range overlap
Expand Down
34 changes: 34 additions & 0 deletions shortcuts/okr/okr_errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package okr

import (
"errors"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)

// okrInputStatError maps a FileIO.Stat/Open error for input file validation to
// a typed validation error: path validation failures read as "unsafe file
// path", other errors as "cannot read file".
func okrInputStatError(err error) error {
if err == nil {
return nil
}
if errors.Is(err, fileio.ErrPathValidation) {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe file path: %s", err).WithCause(err)
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot read file: %s", err).WithCause(err)
}

// wrapOkrNetworkErr returns err unchanged when it is already a typed errs.*
// error (preserving subtype / code / log_id from the runtime boundary) and only
// wraps a raw, unclassified error as a transport-level network error.
func wrapOkrNetworkErr(err error, format string, args ...any) error {
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err)
}
47 changes: 47 additions & 0 deletions shortcuts/okr/okr_errors_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package okr

import (
"errors"
"testing"

"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/extension/fileio"
)

func TestOkrInputStatError(t *testing.T) {
if okrInputStatError(nil) != nil {
t.Fatal("nil error should map to nil")
}

var ve *errs.ValidationError

pathErr := okrInputStatError(&fileio.PathValidationError{Err: errors.New("traversal")})
if !errors.As(pathErr, &ve) || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("path validation: got %T (%v)", pathErr, pathErr)
}

genericErr := okrInputStatError(errors.New("permission denied"))
if !errors.As(genericErr, &ve) || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("generic: got %T (%v)", genericErr, genericErr)
}
}

func TestWrapOkrNetworkErr(t *testing.T) {
typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "already typed")
if got := wrapOkrNetworkErr(typed, "wrap %v", typed); got != error(typed) {
t.Fatalf("typed error must pass through unchanged, got %v", got)
}

raw := errors.New("dial tcp: i/o timeout")
got := wrapOkrNetworkErr(raw, "upload failed: %v", raw)
var ne *errs.NetworkError
if !errors.As(got, &ne) || ne.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("raw error: got %T (%v)", got, got)
}
if !errors.Is(got, raw) {
t.Fatal("raw cause should be retained via WithCause")
}
}
Loading
Loading