Compare commits

..

5 Commits

Author SHA1 Message Date
liangshuo-1
bba13cfe0f chore: release v1.0.56 (#1518) 2026-06-18 18:53:21 +08:00
liujiashu-shiro
815cdb8f1c feat(im/convert): support content_v2 blocks in post message conversion (#1411)
Support content_v2 post message conversion in IM shortcuts so newer post payloads render with the expected markdown, mention, and image formats while preserving fallback compatibility with legacy content.
2026-06-18 17:53:22 +08:00
liangshuo-1
4f3ae0c71a fix: pin fetch_meta.py output to utf-8 encoding (#1516) 2026-06-18 17:18:45 +08:00
91-enjoy
96d70143c5 feat: support message recieve event card format (#1480)
Previously, im.message.receive events with message_type: interactive surfaced the raw JSON
payload as content, requiring callers to manually parse the card schema. This PR introduces a
user_dsl renderer (ConvertInteractiveEventContent) that converts interactive card content into
structured human-readable text — consistent with how text, post, image, and other message
types are already handled.

The output format is <card title="..." subtitle="...">...</card>, with each card element type
serialised to a readable representation (markdown body, button links, table rows, chart summaries,
etc.).
2026-06-18 17:18:01 +08:00
syh-cpdsss
83db15907f Improve OKR shortcuts (#1487)
* feat(okr): add +batch-create, +reorder, +weight shortcuts

Add three new OKR shortcuts for managing objectives and key results:

- +batch-create: Bulk create objectives with key results, with automatic
  rollback on failure
- +reorder: Adjust position of objectives or key results within a cycle/objective
- +weight: Adjust weights of objectives or key results with automatic
  normalization using fixed-point arithmetic to avoid float precision issues

Key implementation details:
- API paths use underscore separators (/objectives_position, /objectives_weight)
- Weight normalization uses json.Number for precise JSON serialization
- Items are sorted by position before API calls to match backend requirements
- Full unit test coverage and dry-run/live E2E tests
- Skill documentation with usage examples and parameter descriptions

Change-Id: I92b658e0cc42ffa8cbdaec2ec628a079bcfc38f5

* fix: skill simplify & minor fix

Change-Id: I3f27a01cdae2122f26e48ee2acb7f334f2bab7d2

* fix: CR issue

Change-Id: Id9fab84e06f0d67e9f79c1fb9946b6b633200592

* fix: CR issue 2

Change-Id: I6a5e57dd4b10dc79f8681ec614354fbba82abc04

* fix: error handle of +weight shortcut

Change-Id: I6e2a39269e62e3b504e681110843b2ccc315a527
2026-06-18 16:25:23 +08:00
37 changed files with 7040 additions and 1117 deletions

View File

@@ -2,6 +2,29 @@
All notable changes to this project will be documented in this file.
## [v1.0.56] - 2026-06-18
### Features
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
### Bug Fixes
- **api**: Align API success envelopes (#1489)
- **base**: Reject out-of-range pagination flags (#1495)
### Refactor
- Retire legacy error envelopes and enforce typed contract (#1449)
### Documentation
- **skills**: Soften lark-doc style guidance (#1463)
### Build
- Add CI quality gate with semantic review
## [v1.0.55] - 2026-06-16
### Features
@@ -1189,6 +1212,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53

View File

@@ -285,18 +285,12 @@ func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
if err == nil {
t.Fatal("expected error for non-terminal without flags")
}
if !strings.Contains(err.Error(), "terminal") {
t.Errorf("expected error to mention terminal, got: %s", err.Error())
msg := err.Error()
if !strings.Contains(msg, "--new") {
t.Errorf("expected error to mention --new, got: %s", msg)
}
// Missing-terminal is a failed precondition (valid request, wrong runtime
// state), and the actionable guidance lives in the hint.
p, ok := errs.ProblemOf(err)
if !ok || p.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("expected subtype=%q, got problem=%+v", errs.SubtypeFailedPrecondition, p)
}
// Lock the two-step guidance contract: the hint must point at both flags.
if !strings.Contains(p.Hint, "--no-wait") || !strings.Contains(p.Hint, "--device-code") {
t.Errorf("hint should describe the two-step flow (--no-wait / --device-code), got: %s", p.Hint)
if !strings.Contains(msg, "terminal") {
t.Errorf("expected error to mention terminal, got: %s", msg)
}
}

View File

@@ -32,13 +32,6 @@ type ConfigInitOptions struct {
Brand string
New bool
// NoWait initiates a new-app creation and returns immediately with a
// device code (non-blocking step 1); DeviceCode completes a creation
// previously started with --no-wait (non-blocking step 2). They mirror
// `auth login`'s --no-wait / --device-code split.
NoWait bool
DeviceCode string
Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
@@ -63,11 +56,9 @@ func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *
Short: "Initialize configuration (app-id / app-secret-stdin / brand)",
Long: `Initialize configuration (app-id / app-secret-stdin / brand).
For AI agents: prefer the non-blocking two-step flow. Run '--new --no-wait' to
get a device code and verification URL immediately (printed as JSON), send the
URL/QR to the user, then run '--device-code <code>' after they confirm to finish.
The plain '--new' still blocks until the user completes setup in the browser if
you need the old behavior.
For AI agents: use --new to create a new app. The command blocks until the user
completes setup in the browser. Run it in the background and retrieve the
verification URL from its output.
Inside an Agent context (OPENCLAW_HOME / HERMES_HOME set) this command
refuses by default — use 'lark-cli config bind' to bind to the Agent's
@@ -90,8 +81,6 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
}
cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "create a new app but return immediately with a device code; complete later with --device-code (non-blocking, for AI agents)")
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "complete a new-app creation started with --no-wait, using its device code")
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
@@ -143,7 +132,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
return o.New || o.AppID != "" || o.AppSecretStdin || o.NoWait || o.DeviceCode != ""
return o.New || o.AppID != "" || o.AppSecretStdin
}
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
@@ -319,22 +308,6 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
func configInitRun(opts *ConfigInitOptions) error {
f := opts.Factory
// Validate the non-blocking flags before touching stdin so a contradictory
// combination (e.g. --no-wait --app-secret-stdin) fails fast instead of
// blocking on a stdin read.
if opts.NoWait && opts.DeviceCode != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--no-wait and --device-code cannot be used together").WithParam("--device-code")
}
if (opts.NoWait || opts.DeviceCode != "") && (opts.AppID != "" || opts.AppSecretStdin) {
// Point remediation at whichever non-blocking flag the caller actually
// passed (mutual exclusion above guarantees at most one is set here).
conflictParam := "--no-wait"
if opts.DeviceCode != "" {
conflictParam = "--device-code"
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--no-wait/--device-code create a new app and cannot be combined with --app-id/--app-secret-stdin").WithParam(conflictParam)
}
// Read secret from stdin if --app-secret-stdin is set
if opts.AppSecretStdin {
scanner := bufio.NewScanner(f.IOStreams.In)
@@ -362,15 +335,6 @@ func configInitRun(opts *ConfigInitOptions) error {
}
}
// Non-blocking step 2: complete a creation started with --no-wait.
if opts.DeviceCode != "" {
return resumeAppRegistration(opts)
}
// Non-blocking step 1: initiate a new-app creation and return immediately.
if opts.NoWait {
return initiateNoWaitAppRegistration(opts, existing)
}
// Mode 1: Non-interactive
if opts.AppID != "" && opts.appSecret != "" {
brand := parseBrand(opts.Brand)
@@ -473,12 +437,9 @@ func configInitRun(opts *ConfigInitOptions) error {
return nil
}
// Non-terminal: the request is valid but the runtime state is wrong (no
// terminal for interactive mode) — a failed precondition, not a bad
// argument. Point the caller at the non-blocking two-step flow.
// Non-terminal: cannot run interactive mode, guide user to --new
if !f.IOStreams.IsTerminal {
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "config init interactive mode requires a terminal").
WithHint("Create a new app non-interactively with the two-step flow: `lark-cli config init --new --no-wait` (prints device_code + verification_url, returns immediately), then `lark-cli config init --device-code <code>` after the user finishes in the browser. Or run `lark-cli config init --new` in a terminal.")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
}
// Mode 5: Legacy interactive (readline fallback)

View File

@@ -182,11 +182,6 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
httpClient := transport.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(httpClient, larkBrand, f.IOStreams.ErrOut)
if err != nil {
// Pass a lower-layer typed error (e.g. a network/transport error) through
// unchanged; only wrap genuinely-untyped failures as invalid_client.
if _, ok := errs.ProblemOf(err); ok {
return nil, err
}
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
}

View File

@@ -1,265 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/transport"
)
// newRegistrationHTTPClient builds the HTTP client used for app-registration
// traffic. It is a package var so tests can inject a stub transport.
var newRegistrationHTTPClient = func() *http.Client { return transport.NewHTTPClient(0) }
// initNoWaitHint is the agent-facing guidance embedded in the --no-wait JSON
// output, mirroring the two-step contract of `auth login --no-wait`.
const initNoWaitHint = "**Generate AND display the QR code:** call `lark-cli auth qrcode <verification_url>` and show it (PNG via --output; ASCII via --ascii only if the user asks). " +
"**You MUST include the QR image in your response** — generating the file alone is not enough. Output the URL first, then the QR image below it. " +
"**Treat verification_url as an opaque string** — do not URL-encode/decode it or add spaces/punctuation. " +
"**Hand control back:** make the QR/URL the final message of this turn; do NOT run --device-code in the same turn. Tell the user to come back and notify you after they finish creating the app in the browser. " +
"**After the user confirms:** YOU must finish by running lark-cli with the exact arguments in `resume_args`, passing each element as a separate literal argument (do not re-quote or shell-interpret them). It already carries the right flags. " +
"**Do NOT cache verification_url or device_code** — run `lark-cli config init --new --no-wait` fresh whenever a new app is needed."
// initiateNoWaitAppRegistration runs the non-blocking first step: request a
// device code, cache the resume context, print JSON, and return immediately
// without polling.
func initiateNoWaitAppRegistration(opts *ConfigInitOptions, existing *core.MultiAppConfig) error {
f := opts.Factory
brand := parseBrand(opts.Brand)
httpClient := newRegistrationHTTPClient()
authResp, err := larkauth.RequestAppRegistration(httpClient, brand, f.IOStreams.ErrOut)
if err != nil {
// Pass a lower-layer typed error (e.g. a network/transport error) through
// unchanged; only wrap genuinely-untyped failures as invalid_client.
if _, ok := errs.ProblemOf(err); ok {
return err
}
return errs.NewConfigError(errs.SubtypeInvalidClient, "app registration failed: %v", err).WithCause(err)
}
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: string(brand),
ProfileName: opts.ProfileName,
Lang: opts.Lang,
LangExplicit: opts.langExplicit,
Interval: authResp.Interval,
ExpiresAt: time.Now().Unix() + int64(authResp.ExpiresIn),
ConfigDigest: computeConfigDigest(existing),
}
// The resume step (--device-code) fully depends on this cache to finish
// persisting the app — unlike auth login, which can re-derive its scope. So
// a cache-write failure is fatal: fail now rather than hand back a
// device_code the user can never complete.
if err := saveInitNoWaitRecord(authResp.DeviceCode, rec); err != nil {
return errs.NewInternalError(errs.SubtypeStorage, "failed to persist the context needed by `config init --device-code`: %v", err).WithCause(err)
}
// Emit the resume step as an argv array rather than a shell string: the
// device_code is opaque and may contain spaces or metacharacters, and a
// single quoted string can't be both POSIX- and cmd.exe-safe. argv sidesteps
// quoting entirely — agents pass each element as a literal argument.
// --force-init must be carried along: guardAgentWorkspace runs in RunE
// before the cache is read, so resuming without it inside an agent workspace
// would be rejected. (Profile name is recovered from the cache.)
resumeArgs := []string{"lark-cli", "config", "init", "--device-code", authResp.DeviceCode}
if opts.ForceInit {
resumeArgs = append(resumeArgs, "--force-init")
}
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
data := map[string]interface{}{
"verification_url": verificationURL,
"device_code": authResp.DeviceCode,
"expires_in": authResp.ExpiresIn,
"resume_args": resumeArgs,
"hint": initNoWaitHint,
}
encoder := json.NewEncoder(f.IOStreams.Out)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "failed to write JSON output: %v", err).WithCause(err)
}
return nil
}
// resumeAppRegistration runs the non-blocking second step: poll with a device
// code from a previous --no-wait call, then persist the new app and probe it.
func resumeAppRegistration(opts *ConfigInitOptions) error {
f := opts.Factory
rec, err := loadInitNoWaitRecord(opts.DeviceCode)
if err != nil {
// The record exists but could not be read/parsed (permissions, disk,
// corruption). The resume step fully depends on this cache, so surface a
// storage error instead of the misleading "no pending creation"
// validation path — the user should fix local storage, not assume the
// device code is bad and throw away a still-valid creation attempt.
return errs.NewInternalError(errs.SubtypeStorage, "failed to read the cached resume context: %v", err).WithCause(err)
}
if rec == nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"no pending app creation found for this device code; re-initiate with `lark-cli config init --new --no-wait`").
WithParam("--device-code")
}
// Expiry check against the cached absolute deadline (device codes are
// short-lived — the registration default is 300s).
remaining := rec.ExpiresAt - time.Now().Unix()
if remaining <= 0 {
_ = removeInitNoWaitRecord(opts.DeviceCode)
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"device code expired; re-initiate with `lark-cli config init --new --no-wait`").
WithParam("--device-code")
}
// Drift guard (fast path): bail out before the long poll if the config
// already changed since initiation, so we don't waste minutes polling.
existing, err := loadConfigForDriftCheck()
if err != nil {
return err
}
if computeConfigDigest(existing) != rec.ConfigDigest {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"configuration changed since this app creation was started; re-initiate with `lark-cli config init --new --no-wait` to avoid overwriting it").
WithParam("--device-code")
}
interval := rec.Interval
if interval <= 0 {
interval = 5
}
httpClient := newRegistrationHTTPClient()
result, pollErr := pollAppRegistrationResume(opts.Ctx, httpClient, opts.DeviceCode, interval, int(remaining), f.IOStreams.ErrOut)
if pollErr != nil {
// Clear the cache only on terminal failures (denied / expired /
// timed-out). Keep it on cancellation or transient errors so the user
// can retry with the same device code while it is still valid.
if appRegShouldClearCache(pollErr) {
_ = removeInitNoWaitRecord(opts.DeviceCode)
}
// Pass an already-typed error through unchanged (e.g. the ConfigError
// for a missing client_id/secret) instead of downgrading it to
// authentication/unknown — matching runCreateAppFlow.
if _, ok := errs.ProblemOf(pollErr); ok {
return pollErr
}
return errs.NewAuthenticationError(errs.SubtypeUnknown, "%v", pollErr).WithCause(pollErr)
}
// Re-check drift immediately before persisting. The poll above can block
// for minutes while the user finishes in the browser, and a concurrent
// process may have changed config.json in that window — saving the stale
// pre-poll snapshot would drop those edits. Reload and compare again.
existing, err = loadConfigForDriftCheck()
if err != nil {
return err
}
if computeConfigDigest(existing) != rec.ConfigDigest {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"configuration changed while the app was being created, so it was not saved (to avoid overwriting that change); re-run `lark-cli config init --new --no-wait`").
WithParam("--device-code")
}
// Determine the final brand from the response, falling back to the cached
// brand. The cached brand only seeds link generation + this fallback; the
// Lark-tenant re-poll inside pollAppRegistrationResume is what actually
// detects a Lark tenant.
finalBrand := parseBrand(rec.Brand)
if result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
finalBrand = core.BrandLark
} else if result.UserInfo != nil && result.UserInfo.TenantBrand == "feishu" {
finalBrand = core.BrandFeishu
}
secret, err := core.ForStorage(result.ClientID, core.PlainSecret(result.ClientSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
}
if err := saveInitConfig(rec.ProfileName, existing, f, result.ClientID, secret, finalBrand, rec.Lang); err != nil {
// Preserve a typed error (e.g. the --name conflict ValidationError) via
// the shared helper instead of downgrading everything to storage —
// matching the blocking init paths.
return wrapSaveConfigError(err)
}
// Config persisted — only now is it safe to drop the resume cache. Clearing
// it only after a successful save means a failure in the drift re-check,
// ForStorage, or saveInitConfig above leaves the cache intact so the user
// can retry `--device-code` (the remote app already exists).
_ = removeInitNoWaitRecord(opts.DeviceCode)
if rec.LangExplicit && rec.Lang != "" {
msg := getInitMsg(opts.UILang)
fmt.Fprintln(f.IOStreams.ErrOut, fmt.Sprintf(msg.LangPreferenceSet, rec.Lang))
}
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.ClientID, "appSecret": "****", "brand": finalBrand})
if err := runProbe(opts.Ctx, f, result.ClientID, result.ClientSecret, finalBrand); err != nil {
return err
}
return nil
}
// pollAppRegistrationResume polls the registration endpoint (feishu first, then
// the lark endpoint on the tenant_brand=lark special case) and returns the raw
// error so the caller can classify it for cache-cleanup decisions.
func pollAppRegistrationResume(ctx context.Context, httpClient *http.Client, deviceCode string, interval, expiresIn int, errOut io.Writer) (*larkauth.AppRegistrationResult, error) {
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, deviceCode, interval, expiresIn, errOut)
if err != nil {
return nil, err
}
// Lark tenant special case: if tenant_brand=lark and no client_secret,
// re-poll against the lark endpoint to obtain the secret.
if result.ClientSecret == "" && result.UserInfo != nil && result.UserInfo.TenantBrand == "lark" {
result, err = larkauth.PollAppRegistration(ctx, httpClient, core.BrandLark, deviceCode, interval, expiresIn, errOut)
if err != nil {
return nil, err
}
}
if result.ClientID == "" || result.ClientSecret == "" {
return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "app registration succeeded but missing client_id or client_secret")
}
return result, nil
}
// appRegShouldClearCache reports whether the cached resume context should be
// discarded after a poll outcome. Success and terminal failures (user denied,
// device code expired, deadline elapsed) clear it; cancellation and transient
// errors keep it so the user can retry while the device code is still valid.
func appRegShouldClearCache(err error) bool {
if err == nil {
return true
}
return errors.Is(err, larkauth.ErrAppRegDenied) ||
errors.Is(err, larkauth.ErrAppRegExpired) ||
errors.Is(err, larkauth.ErrAppRegTimeout)
}
// loadConfigForDriftCheck loads the config for the drift comparison. A missing
// config (first-time setup) is fine — it yields a nil config and an empty
// digest. A genuine storage failure (permission denied, corruption) is surfaced
// as a typed storage error rather than being silently read as "config drift".
func loadConfigForDriftCheck() (*core.MultiAppConfig, error) {
existing, err := core.LoadMultiAppConfig()
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, errs.NewInternalError(errs.SubtypeStorage, "failed to load config for the drift check: %v", err).WithCause(err)
}
return existing, nil
}

View File

@@ -1,116 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
)
// initNoWaitCacheVersion is the schema version of the cached init context.
// Bump it when the record shape changes so stale entries are ignored.
const initNoWaitCacheVersion = 1
// initNoWaitRecord is the context persisted by `config init --new --no-wait` so
// that the later `--device-code` step can complete the app creation. It must
// never hold a secret, verification URL, or full config — only what the resume
// step needs to finish persisting the new app.
type initNoWaitRecord struct {
Version int `json:"version"`
Brand string `json:"brand"`
ProfileName string `json:"profile_name"`
Lang string `json:"lang"`
LangExplicit bool `json:"lang_explicit"`
Interval int `json:"interval"`
ExpiresAt int64 `json:"expires_at"` // unix seconds; absolute device-code deadline
ConfigDigest string `json:"config_digest"`
}
// initNoWaitCacheDir returns the directory used to persist config init
// --no-wait context keyed by device_code.
func initNoWaitCacheDir() string {
return filepath.Join(core.GetConfigDir(), "cache", "config_init_nowait")
}
// initNoWaitCachePath returns the cache file path for a given device_code.
func initNoWaitCachePath(deviceCode string) string {
return filepath.Join(initNoWaitCacheDir(), initNoWaitCacheKey(deviceCode)+".json")
}
// initNoWaitCacheKey derives a collision-free, filesystem-safe filename token
// from an opaque device_code. A sha256 hex digest avoids the collisions a
// character-replacement sanitizer would cause (e.g. "a/b" and "a:b" both
// mapping to "a_b").
func initNoWaitCacheKey(deviceCode string) string {
sum := sha256.Sum256([]byte(deviceCode))
return hex.EncodeToString(sum[:])
}
// saveInitNoWaitRecord persists the resume context for a device_code.
func saveInitNoWaitRecord(deviceCode string, rec initNoWaitRecord) error {
if err := vfs.MkdirAll(initNoWaitCacheDir(), 0700); err != nil {
return err
}
data, err := json.Marshal(rec)
if err != nil {
return err
}
return validate.AtomicWrite(initNoWaitCachePath(deviceCode), data, 0600)
}
// loadInitNoWaitRecord loads the resume context for a device_code. It returns
// (nil, nil) when no cache entry exists.
func loadInitNoWaitRecord(deviceCode string) (*initNoWaitRecord, error) {
data, err := vfs.ReadFile(initNoWaitCachePath(deviceCode))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
return nil, err
}
var rec initNoWaitRecord
if err := json.Unmarshal(data, &rec); err != nil {
_ = vfs.Remove(initNoWaitCachePath(deviceCode))
return nil, err
}
if rec.Version != initNoWaitCacheVersion {
_ = vfs.Remove(initNoWaitCachePath(deviceCode))
return nil, nil
}
return &rec, nil
}
// removeInitNoWaitRecord deletes the cache entry for a device_code.
func removeInitNoWaitRecord(deviceCode string) error {
err := vfs.Remove(initNoWaitCachePath(deviceCode))
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// computeConfigDigest returns a stable digest of the existing config so the
// resume step can detect drift between initiation and completion. The digest
// is a hash of config.json content (app IDs, brands, users, secret references)
// — it contains no plaintext secret and is safe to cache. A nil config and an
// (unexpected) marshal error both map to the empty digest.
func computeConfigDigest(existing *core.MultiAppConfig) string {
if existing == nil {
return ""
}
data, err := json.Marshal(existing)
if err != nil {
return ""
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}

View File

@@ -1,521 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package config
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
larkauth "github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)
// roundTripFunc adapts a function to an http.RoundTripper.
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// TestNoWait_InitiateThenResume_EndToEnd drives the full two-step flow against a
// real local HTTP server: initiate writes the on-disk cache, then a SEPARATE
// resume call polls the same server, succeeds, and persists the new app. Only
// the device_code + the cache bridge the two invocations — exactly as the two
// CLI commands would. (A black-box binary E2E of the success path is impossible
// without a human: endpoints are hardcoded HTTPS and the real device flow needs
// a browser scan, so this in-process run through httptest is the highest-fidelity
// autonomous end-to-end.)
func TestNoWait_InitiateThenResume_EndToEnd(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
switch r.FormValue("action") {
case "begin":
_, _ = w.Write([]byte(`{"device_code":"E2E-DEVICE-CODE","user_code":"E2E-UC","verification_uri":"https://example.test/verify","expires_in":600,"interval":1}`))
case "poll":
_, _ = w.Write([]byte(`{"client_id":"cli_e2e","client_secret":"sec_e2e","user_info":{"tenant_brand":"feishu","open_id":"ou_e2e"}}`))
default:
http.Error(w, "unexpected action "+r.FormValue("action"), http.StatusBadRequest)
}
}))
defer ts.Close()
tsURL, _ := url.Parse(ts.URL)
// Redirect the registration client to the local test server.
orig := newRegistrationHTTPClient
newRegistrationHTTPClient = func() *http.Client {
return &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
r.URL.Scheme, r.URL.Host = tsURL.Scheme, tsURL.Host
return http.DefaultTransport.RoundTrip(r)
})}
}
t.Cleanup(func() { newRegistrationHTTPClient = orig })
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
// Step 1 — initiate: should print device_code and write the resume cache.
initOpts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Brand: "feishu", New: true, NoWait: true}
if err := initiateNoWaitAppRegistration(initOpts, nil); err != nil {
t.Fatalf("initiate: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("initiate stdout not JSON: %v; raw=%s", err, stdout.String())
}
if out["device_code"] != "E2E-DEVICE-CODE" {
t.Fatalf("device_code = %v, want E2E-DEVICE-CODE", out["device_code"])
}
if rec, _ := loadInitNoWaitRecord("E2E-DEVICE-CODE"); rec == nil {
t.Fatal("initiate did not write the resume cache")
}
// Step 2 — resume (separate invocation; bridged only by device_code + cache).
stdout.Reset()
resumeOpts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "E2E-DEVICE-CODE"}
if err := resumeAppRegistration(resumeOpts); err != nil {
t.Fatalf("resume: %v", err)
}
// The new app must be persisted to config...
cfg, err := core.LoadMultiAppConfig()
if err != nil || cfg == nil {
t.Fatalf("config not persisted: %v", err)
}
if app := cfg.CurrentAppConfig(""); app == nil || app.AppId != "cli_e2e" {
t.Fatalf("persisted app = %+v, want AppId cli_e2e", app)
}
// ...the cache cleared after the successful save...
if rec, _ := loadInitNoWaitRecord("E2E-DEVICE-CODE"); rec != nil {
t.Error("resume should clear the cache after a successful save")
}
// ...and the success JSON emitted.
if !strings.Contains(stdout.String(), "cli_e2e") {
t.Errorf("resume stdout missing appId: %s", stdout.String())
}
}
// stubRT returns a single canned HTTP response for every request.
type stubRT struct {
status int
body string
}
func (s stubRT) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{StatusCode: s.status, Body: io.NopCloser(strings.NewReader(s.body)), Header: make(http.Header)}, nil
}
// seqRT returns successive canned responses (last one repeats), for flows that
// poll more than once (e.g. the Lark-tenant re-poll).
type seqRT struct {
bodies []string
i int
}
func (s *seqRT) RoundTrip(*http.Request) (*http.Response, error) {
idx := s.i
if idx >= len(s.bodies) {
idx = len(s.bodies) - 1
}
s.i++
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(s.bodies[idx])), Header: make(http.Header)}, nil
}
// withStubRegistrationClient swaps the registration HTTP client for the test.
func withStubRegistrationClient(t *testing.T, rt http.RoundTripper) {
t.Helper()
orig := newRegistrationHTTPClient
newRegistrationHTTPClient = func() *http.Client { return &http.Client{Transport: rt} }
t.Cleanup(func() { newRegistrationHTTPClient = orig })
}
// --- cache round-trip ---
func TestInitNoWaitCache_RoundTrip(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: "feishu",
ProfileName: "work",
Lang: "zh_cn",
LangExplicit: true,
Interval: 5,
ExpiresAt: time.Now().Unix() + 300,
ConfigDigest: "abc123",
}
const dc = "device-code-xyz"
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save: %v", err)
}
got, err := loadInitNoWaitRecord(dc)
if err != nil {
t.Fatalf("load: %v", err)
}
if got == nil {
t.Fatal("load returned nil for a saved record")
}
if *got != rec {
t.Errorf("round-trip mismatch:\n got %+v\n want %+v", *got, rec)
}
if err := removeInitNoWaitRecord(dc); err != nil {
t.Fatalf("remove: %v", err)
}
got2, err := loadInitNoWaitRecord(dc)
if err != nil {
t.Fatalf("load after remove: %v", err)
}
if got2 != nil {
t.Errorf("expected nil after remove, got %+v", got2)
}
// Removing a non-existent record must be a no-op, not an error.
if err := removeInitNoWaitRecord(dc); err != nil {
t.Errorf("remove of missing record should be nil, got %v", err)
}
}
func TestInitNoWaitCache_LoadMissing(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
got, err := loadInitNoWaitRecord("never-saved")
if err != nil {
t.Fatalf("load missing: %v", err)
}
if got != nil {
t.Errorf("expected nil for missing record, got %+v", got)
}
}
func TestInitNoWaitCache_VersionMismatchIgnored(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
const dc = "stale-version"
rec := initNoWaitRecord{Version: initNoWaitCacheVersion + 1, ExpiresAt: time.Now().Unix() + 300}
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save: %v", err)
}
got, err := loadInitNoWaitRecord(dc)
if err != nil {
t.Fatalf("load: %v", err)
}
if got != nil {
t.Errorf("expected nil for version mismatch, got %+v", got)
}
// The stale entry should have been discarded by the load.
got2, _ := loadInitNoWaitRecord(dc)
if got2 != nil {
t.Errorf("stale-version entry was not removed on load")
}
}
func TestInitNoWaitCacheKey(t *testing.T) {
// Distinct device codes that a char-replacement sanitizer would collide
// ("a/b" and "a:b" -> "a_b") must map to distinct keys.
if initNoWaitCacheKey("a/b") == initNoWaitCacheKey("a:b") {
t.Error("distinct device codes must not collide on the cache key")
}
// Deterministic.
if initNoWaitCacheKey("xyz") != initNoWaitCacheKey("xyz") {
t.Error("cache key must be deterministic")
}
// sha256 hex: 64 chars, filesystem-safe regardless of input.
k := initNoWaitCacheKey("has /, :, ;, spaces and 'quotes'")
if len(k) != 64 {
t.Errorf("expected 64-char sha256 hex key, got %d: %q", len(k), k)
}
}
// --- config digest ---
func TestComputeConfigDigest(t *testing.T) {
if d := computeConfigDigest(nil); d != "" {
t.Errorf("nil digest = %q, want empty", d)
}
cfg1 := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_a", Brand: core.BrandFeishu}}}
cfg1Dup := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_a", Brand: core.BrandFeishu}}}
cfg2 := &core.MultiAppConfig{Apps: []core.AppConfig{{AppId: "cli_b", Brand: core.BrandFeishu}}}
if computeConfigDigest(cfg1) == "" {
t.Error("non-nil config digest should be non-empty")
}
if computeConfigDigest(cfg1) != computeConfigDigest(cfg1Dup) {
t.Error("equal configs should produce equal digests")
}
if computeConfigDigest(cfg1) == computeConfigDigest(cfg2) {
t.Error("different configs should produce different digests")
}
}
// --- failure classification for cache cleanup ---
func TestAppRegShouldClearCache(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"success", nil, true},
{"denied", larkauth.ErrAppRegDenied, true},
{"expired", larkauth.ErrAppRegExpired, true},
{"expired wrapped", fmt.Errorf("%w, please try again", larkauth.ErrAppRegExpired), true},
{"timeout", larkauth.ErrAppRegTimeout, true},
{"timeout wrapped", fmt.Errorf("%w, please try again", larkauth.ErrAppRegTimeout), true},
{"cancelled", larkauth.ErrAppRegCancelled, false},
{"transient generic", fmt.Errorf("network boom"), false},
{"missing fields", fmt.Errorf("app registration succeeded but missing client_id or client_secret"), false},
}
for _, c := range cases {
if got := appRegShouldClearCache(c.err); got != c.want {
t.Errorf("%s: appRegShouldClearCache = %v, want %v", c.name, got, c.want)
}
}
}
// --- initiate (stubbed registration client) ---
func TestInitiateNoWaitAppRegistration_WritesCacheAndJSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
withStubRegistrationClient(t, stubRT{200, `{"device_code":"dc-abc","user_code":"U-1","verification_uri":"https://open.feishu.cn","expires_in":3600,"interval":5}`})
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), Brand: "feishu", New: true, NoWait: true, ForceInit: true}
if err := initiateNoWaitAppRegistration(opts, nil); err != nil {
t.Fatalf("initiate: %v", err)
}
var out map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &out); err != nil {
t.Fatalf("stdout not JSON: %v; raw=%s", err, stdout.String())
}
if out["device_code"] != "dc-abc" {
t.Errorf("device_code = %v, want dc-abc", out["device_code"])
}
args, ok := out["resume_args"].([]interface{})
if !ok || len(args) == 0 || args[len(args)-1] != "--force-init" {
t.Errorf("resume_args should end with --force-init, got %v", out["resume_args"])
}
rec, _ := loadInitNoWaitRecord("dc-abc")
if rec == nil {
t.Fatal("cache record not written")
}
if rec.Brand != "feishu" || rec.Version != initNoWaitCacheVersion {
t.Errorf("cache record = %+v", *rec)
}
}
// --- pollAppRegistrationResume (stubbed client) ---
func TestPollAppRegistrationResume_Success(t *testing.T) {
c := &http.Client{Transport: stubRT{200, `{"client_id":"cli_x","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`}}
res, err := pollAppRegistrationResume(context.Background(), c, "dc", 0, 60, io.Discard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.ClientID != "cli_x" || res.ClientSecret != "sec" {
t.Errorf("got %+v", res)
}
}
func TestPollAppRegistrationResume_MissingSecret(t *testing.T) {
c := &http.Client{Transport: stubRT{200, `{"client_id":"cli_x"}`}}
if _, err := pollAppRegistrationResume(context.Background(), c, "dc", 0, 60, io.Discard); err == nil {
t.Error("expected error when client_secret is missing")
}
}
func TestPollAppRegistrationResume_LarkRetry(t *testing.T) {
// First poll (feishu endpoint): lark tenant, no secret -> triggers re-poll
// against the lark endpoint, which returns the secret.
rt := &seqRT{bodies: []string{
`{"client_id":"cli_x","client_secret":"","user_info":{"tenant_brand":"lark"}}`,
`{"client_id":"cli_x","client_secret":"larksec","user_info":{"tenant_brand":"lark"}}`,
}}
res, err := pollAppRegistrationResume(context.Background(), &http.Client{Transport: rt}, "dc", 0, 60, io.Discard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.ClientSecret != "larksec" {
t.Errorf("expected lark re-poll to yield the secret, got %+v", res)
}
}
// Full resume happy path: stubbed poll succeeds, the app is persisted, and the
// cache is cleared. (runProbe hits the factory's mock client, which has no stub
// and returns an untyped error that runProbe swallows.)
func TestResumeAppRegistration_Success(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
withStubRegistrationClient(t, stubRT{200, `{"client_id":"cli_new","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`})
const dc = "resume-ok"
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: "feishu",
Interval: 1, // keep the single poll fast
ExpiresAt: time.Now().Unix() + 300,
ConfigDigest: computeConfigDigest(nil),
}
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save: %v", err)
}
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
if err := resumeAppRegistration(opts); err != nil {
t.Fatalf("resume: %v", err)
}
cfg, _ := core.LoadMultiAppConfig()
if cfg == nil || cfg.CurrentAppConfig("") == nil || cfg.CurrentAppConfig("").AppId != "cli_new" {
t.Errorf("config not persisted with new app id: %+v", cfg)
}
if got, _ := loadInitNoWaitRecord(dc); got != nil {
t.Error("cache should be cleared after a successful save")
}
if !strings.Contains(stdout.String(), "cli_new") {
t.Errorf("stdout missing new appId: %s", stdout.String())
}
}
// A profile-name conflict on the resume save path must surface as the typed
// ValidationError(--name), not be downgraded to an internal/storage error.
func TestResumeAppRegistration_ProfileNameConflict_PreservesValidationError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
withStubRegistrationClient(t, stubRT{200, `{"client_id":"cli_new","client_secret":"sec","user_info":{"tenant_brand":"feishu"}}`})
// Seed a config whose app id collides with the profile name we resume into.
seeded := &core.MultiAppConfig{Apps: []core.AppConfig{
{AppId: "cli_existing", AppSecret: core.PlainSecret("s"), Brand: core.BrandFeishu},
}}
if err := core.SaveMultiAppConfig(seeded); err != nil {
t.Fatalf("seed config: %v", err)
}
loaded, _ := core.LoadMultiAppConfig() // digest must match what resume recomputes
const dc = "conflict-dc"
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: "feishu",
ProfileName: "cli_existing", // collides with the existing appId in saveAsProfile
Interval: 1,
ExpiresAt: time.Now().Unix() + 300,
ConfigDigest: computeConfigDigest(loaded),
}
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save cache: %v", err)
}
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
assertValidationParam(t, resumeAppRegistration(opts), "--name")
}
// --- flag validation (returns before any network) ---
func TestConfigInitRun_NoWaitAndDeviceCodeMutuallyExclusive(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), NoWait: true, DeviceCode: "x"}
assertValidationParam(t, configInitRun(opts), "--device-code")
}
func TestConfigInitRun_NoWaitWithAppIDRejected(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), NoWait: true, AppID: "cli_x"}
assertValidationParam(t, configInitRun(opts), "--no-wait")
}
// The conflict error must point at the flag the caller actually passed: with
// --device-code (not --no-wait) + --app-id, remediation should name --device-code.
func TestConfigInitRun_DeviceCodeWithAppIDReportsDeviceCode(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "dc", AppID: "cli_x"}
assertValidationParam(t, configInitRun(opts), "--device-code")
}
// --- resume guards (return before any network) ---
func TestResumeAppRegistration_NoCacheEntry(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: "missing-dc"}
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
}
func TestResumeAppRegistration_ExpiredClearsCache(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
const dc = "expired-dc"
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: "feishu",
Interval: 5,
ExpiresAt: time.Now().Unix() - 10, // already past
}
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save: %v", err)
}
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
if got, _ := loadInitNoWaitRecord(dc); got != nil {
t.Error("expired cache entry should have been removed")
}
}
// A cache file that exists but cannot be parsed is a storage failure, not a
// "no pending creation" validation error — the user should fix storage rather
// than assume the device code is bad.
func TestResumeAppRegistration_CorruptCacheIsStorageError(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
const dc = "corrupt-dc"
if err := os.MkdirAll(initNoWaitCacheDir(), 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(initNoWaitCachePath(dc), []byte("{ not valid json"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
err := resumeAppRegistration(opts)
var intErr *errs.InternalError
if !errors.As(err, &intErr) {
t.Fatalf("expected *errs.InternalError for unreadable cache, got %T: %v", err, err)
}
if p, ok := errs.ProblemOf(err); !ok || p.Subtype != errs.SubtypeStorage {
t.Fatalf("expected subtype=%q, got problem=%+v", errs.SubtypeStorage, p)
}
if errors.Unwrap(err) == nil {
t.Fatal("expected the underlying cache-read failure to be preserved as a cause")
}
}
func TestResumeAppRegistration_ConfigDrift(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
const dc = "drift-dc"
rec := initNoWaitRecord{
Version: initNoWaitCacheVersion,
Brand: "feishu",
Interval: 5,
ExpiresAt: time.Now().Unix() + 300,
ConfigDigest: "stale-digest-that-will-not-match-current-config",
}
if err := saveInitNoWaitRecord(dc, rec); err != nil {
t.Fatalf("save: %v", err)
}
opts := &ConfigInitOptions{Factory: f, Ctx: context.Background(), DeviceCode: dc}
assertValidationParam(t, resumeAppRegistration(opts), "--device-code")
}

View File

@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -55,8 +55,10 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
}
msg := envelope.Event.Message
content := msg.Content
if msg.MessageType != "interactive" {
var content string
if msg.MessageType == "interactive" {
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
} else {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),

View File

@@ -6,7 +6,6 @@ package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@@ -14,24 +13,9 @@ import (
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
)
// Sentinel errors returned by PollAppRegistration so callers can classify a
// failure (e.g. to decide whether a cached device code should be discarded)
// via errors.Is without parsing message strings.
var (
// ErrAppRegDenied means the user rejected the app registration.
ErrAppRegDenied = errors.New("app registration denied by user")
// ErrAppRegExpired means the device code is no longer valid.
ErrAppRegExpired = errors.New("device code expired")
// ErrAppRegCancelled means polling was cancelled via the context.
ErrAppRegCancelled = errors.New("polling was cancelled")
// ErrAppRegTimeout means the local polling deadline elapsed.
ErrAppRegTimeout = errors.New("app registration timed out")
)
// AppRegistrationResponse is the response from the app registration begin endpoint.
type AppRegistrationResponse struct {
DeviceCode string
@@ -79,7 +63,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
resp, err := httpClient.Do(req)
if err != nil {
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "app registration request failed: %v", err).WithCause(err)
return nil, err
}
defer resp.Body.Close()
logHTTPResponse(resp)
@@ -154,13 +138,13 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
for time.Now().Before(deadline) && attempts < maxPollAttempts {
attempts++
if ctx.Err() != nil {
return nil, ErrAppRegCancelled
return nil, fmt.Errorf("polling was cancelled")
}
select {
case <-time.After(time.Duration(currentInterval) * time.Second):
case <-ctx.Done():
return nil, ErrAppRegCancelled
return nil, fmt.Errorf("polling was cancelled")
}
form := url.Values{}
@@ -221,9 +205,9 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
fmt.Fprintf(errOut, "[lark-cli] app-registration: slow_down, interval increased to %ds\n", currentInterval)
continue
case "access_denied":
return nil, ErrAppRegDenied
return nil, fmt.Errorf("app registration denied by user")
case "expired_token", "invalid_grant":
return nil, fmt.Errorf("%w, please try again", ErrAppRegExpired)
return nil, fmt.Errorf("device code expired, please try again")
}
desc := getStr(data, "error_description")
@@ -239,5 +223,5 @@ func PollAppRegistration(ctx context.Context, httpClient *http.Client, brand cor
if attempts >= maxPollAttempts {
fmt.Fprintf(errOut, "[lark-cli] [WARN] app-registration: max poll attempts (%d) reached\n", maxPollAttempts)
}
return nil, fmt.Errorf("%w, please try again", ErrAppRegTimeout)
return nil, fmt.Errorf("app registration timed out, please try again")
}

View File

@@ -1,95 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package auth
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
)
// stubRoundTripper returns a canned response for every request.
type stubRoundTripper struct {
status int
body string
}
func (s stubRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: s.status,
Body: io.NopCloser(strings.NewReader(s.body)),
Header: make(http.Header),
}, nil
}
// TestAppRegSentinelMessages locks the user-facing message text so the
// interactive create flow (which renders these via "%v") does not regress when
// the errors gained errors.Is support.
func TestAppRegSentinelMessages(t *testing.T) {
cases := map[string]string{
ErrAppRegDenied.Error(): "app registration denied by user",
ErrAppRegCancelled.Error(): "polling was cancelled",
fmt.Errorf("%w, please try again", ErrAppRegExpired).Error(): "device code expired, please try again",
fmt.Errorf("%w, please try again", ErrAppRegTimeout).Error(): "app registration timed out, please try again",
}
for got, want := range cases {
if got != want {
t.Errorf("message = %q, want %q", got, want)
}
}
}
// TestPollAppRegistration_Classifies verifies that terminal poll outcomes are
// returned as the matching sentinel error (interval 0 keeps the test fast).
func TestPollAppRegistration_Classifies(t *testing.T) {
cases := []struct {
name string
body string
want error
}{
{"access_denied", `{"error":"access_denied"}`, ErrAppRegDenied},
{"expired_token", `{"error":"expired_token"}`, ErrAppRegExpired},
{"invalid_grant", `{"error":"invalid_grant"}`, ErrAppRegExpired},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
client := &http.Client{Transport: stubRoundTripper{status: 200, body: c.body}}
_, err := PollAppRegistration(context.Background(), client, core.BrandFeishu, "dc", 0, 60, io.Discard)
if !errors.Is(err, c.want) {
t.Fatalf("err = %v, want errors.Is(%v)", err, c.want)
}
})
}
}
func TestPollAppRegistration_Success(t *testing.T) {
body := `{"client_id":"cli_x","client_secret":"sec","user_info":{"tenant_brand":"feishu","open_id":"ou_1"}}`
client := &http.Client{Transport: stubRoundTripper{status: 200, body: body}}
res, err := PollAppRegistration(context.Background(), client, core.BrandFeishu, "dc", 0, 60, io.Discard)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if res.ClientID != "cli_x" || res.ClientSecret != "sec" {
t.Errorf("got client_id=%q secret=%q, want cli_x/sec", res.ClientID, res.ClientSecret)
}
if res.UserInfo == nil || res.UserInfo.TenantBrand != "feishu" {
t.Errorf("user info not parsed: %+v", res.UserInfo)
}
}
func TestPollAppRegistration_CancelledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancel up front
client := &http.Client{Transport: stubRoundTripper{status: 200, body: `{"error":"authorization_pending"}`}}
_, err := PollAppRegistration(ctx, client, core.BrandFeishu, "dc", 0, 60, io.Discard)
if !errors.Is(err, ErrAppRegCancelled) {
t.Fatalf("err = %v, want errors.Is(ErrAppRegCancelled)", err)
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.55",
"version": "1.0.56",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -89,7 +89,7 @@ def main():
count = len(data.get("services", []))
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
with open(OUT_PATH, "w") as fp:
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.write("\n")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,993 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"encoding/json"
"strings"
"testing"
)
func TestConvertInteractiveEventContent(t *testing.T) {
// invalid JSON → fallback
if got := ConvertInteractiveEventContent("not-json", nil); got != "[interactive card]" {
t.Fatalf("invalid JSON = %q, want [interactive card]", got)
}
// missing user_dsl → fallback
if got := ConvertInteractiveEventContent(`{"other":"field"}`, nil); got != "[interactive card]" {
t.Fatalf("missing user_dsl = %q, want [interactive card]", got)
}
// empty user_dsl → fallback
if got := ConvertInteractiveEventContent(`{"user_dsl":""}`, nil); got != "[interactive card]" {
t.Fatalf("empty user_dsl = %q, want [interactive card]", got)
}
// user_dsl that is not a string (wrong type) → fallback
if got := ConvertInteractiveEventContent(`{"user_dsl":123}`, nil); got != "[interactive card]" {
t.Fatalf("non-string user_dsl = %q, want [interactive card]", got)
}
// valid user-2 card → <card> output
userDsl := `{"schema":"2.0","header":{"title":{"tag":"plain_text","content":"Hello"}},"body":{"elements":[{"tag":"markdown","content":"world"}]}}`
rawContent := `{"user_dsl":"` + strings.ReplaceAll(userDsl, `"`, `\"`) + `"}`
got := ConvertInteractiveEventContent(rawContent, nil)
if !strings.HasPrefix(got, `<card title="Hello">`) {
t.Fatalf("valid card = %q, want prefix <card title=\"Hello\">", got)
}
if !strings.Contains(got, "world") {
t.Fatalf("valid card = %q, want to contain 'world'", got)
}
}
func makeMentionCard(mdContent string) string {
obj := map[string]interface{}{
"schema": "2.0",
"header": map[string]interface{}{
"title": map[string]interface{}{"tag": "plain_text", "content": "T"},
},
"body": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{"tag": "markdown", "content": mdContent},
},
},
}
dslBytes, _ := json.Marshal(obj)
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
return string(raw)
}
func TestConvertInteractiveEventContentMentions(t *testing.T) {
mentions := []interface{}{
map[string]interface{}{
"key": "@_user_1",
"name": "test-user",
"id": map[string]interface{}{"open_id": "fake-uid-001"},
},
}
// quoted attrs: mention_key="key"
got := ConvertInteractiveEventContent(makeMentionCard(`hi <at mention_key="@_user_1">n</at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("quoted mention_key not resolved, got: %s", got)
}
// unquoted attrs (real Lark format): <at id=ou_xxx mention_key=@_user_1></at>
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_user_1></at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("unquoted mention_key not resolved, got: %s", got)
}
// mentions_key variant (unquoted)
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at mentions_key=@_user_1></at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("unquoted mentions_key not resolved, got: %s", got)
}
// degradation 1: no mention_key/mentions_key attr → fall back to @id (unquoted)
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001></at> done`), mentions)
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("no mention_key unquoted: expected @id fallback, got: %s", got)
}
// degradation 2: mention_key not found in mentions → fall back to @id
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_unknown></at> done`), mentions)
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("key not in mentions: expected @id fallback, got: %s", got)
}
// multi-mention: ids=id1,id2,id3 mentions_key=k1,,k3
// k1 hits → @name(id1), k2 empty → @id2 fallback, k3 not found → @id3 fallback
got = ConvertInteractiveEventContent(
makeMentionCard(`<at ids=fake-uid-001,fake-uid-002,fake-uid-003 mentions_key=@_user_1,,@_unknown></at>`),
mentions,
)
want := "@test-user(fake-uid-001)@fake-uid-002@fake-uid-003"
if !strings.Contains(got, want) {
t.Fatalf("multi-mention unquoted: want %q in output, got: %s", want, got)
}
}
func TestUserDslConverterSchema(t *testing.T) {
c := &userDslConverter{}
// user-2.ts: schema field present, header at root, body.elements
schema2 := cardObj{
"schema": "2.0",
"header": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Schema2 Title"},
"subtitle": cardObj{"tag": "plain_text", "content": "Sub"},
},
"body": cardObj{
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "body text"},
},
},
}
got := c.convert(schema2)
if got != "<card title=\"Schema2 Title\" subtitle=\"Sub\">\nbody text\n</card>" {
t.Fatalf("schema2 = %q", got)
}
// user-1.ts: no schema field, i18n_header.zh_cn, elements at root
schema1 := cardObj{
"i18n_header": cardObj{
"zh_cn": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Schema1 Title"},
},
},
"elements": []interface{}{
cardObj{"tag": "hr"},
},
}
got = c.convert(schema1)
if got != "<card title=\"Schema1 Title\">\n---\n</card>" {
t.Fatalf("schema1 = %q", got)
}
// user-1.ts: no schema, direct header (real Lark event format)
schema1Direct := cardObj{
"header": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Direct Header Title"},
"subtitle": cardObj{"tag": "plain_text", "content": "Direct Sub"},
},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "direct body"},
},
}
got = c.convert(schema1Direct)
if got != "<card title=\"Direct Header Title\" subtitle=\"Direct Sub\">\ndirect body\n</card>" {
t.Fatalf("schema1 direct header = %q", got)
}
// no header, no elements → fallback
got = c.convert(cardObj{})
if got != "[interactive card]" {
t.Fatalf("empty card = %q, want [interactive card]", got)
}
// card with title only → valid (not "[interactive card]")
titleOnly := cardObj{
"schema": "2.0",
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "TitleOnly"}},
"body": cardObj{"elements": []interface{}{}},
}
got = c.convert(titleOnly)
if !strings.Contains(got, "TitleOnly") {
t.Fatalf("title-only card = %q, want to contain TitleOnly", got)
}
}
func TestUserDslConverterDispatch(t *testing.T) {
c := &userDslConverter{}
tests := []struct {
name string
elem cardObj
want string
contains string
}{
{
name: "plain_text",
elem: cardObj{"tag": "plain_text", "content": "hello"},
want: "hello",
},
{
name: "markdown",
elem: cardObj{"tag": "markdown", "content": "**bold**"},
want: "**bold**",
},
{
name: "hr",
elem: cardObj{"tag": "hr"},
want: "---",
},
{
name: "br",
elem: cardObj{"tag": "br"},
want: "\n",
},
{
name: "img with img_key",
elem: cardObj{
"tag": "img",
"img_key": "img_v3_abc",
"alt": cardObj{"tag": "plain_text", "content": "Banner"},
},
want: "🖼️ Banner(img_key:img_v3_abc)",
},
{
name: "img_combination",
elem: cardObj{
"tag": "img_combination",
"img_list": []interface{}{
cardObj{"img_key": "k1"},
cardObj{"img_key": "k2"},
},
},
want: "🖼️ 2 image(s)(keys:k1,k2)",
},
{
name: "button with behaviors default_url",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Open"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com"},
},
},
want: "[Open](https://example.com)",
},
{
name: "button disabled",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Nope"},
"disabled": true,
},
want: "[Nope ✗]",
},
{
name: "button no url",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Submit"},
},
want: "[Submit]",
},
{
name: "action wrapper (user-1 style)",
elem: cardObj{
"tag": "action",
"actions": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "A"}},
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "B"}},
},
},
want: "[A] [B]",
},
{
name: "overflow",
elem: cardObj{
"tag": "overflow",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Edit"}},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Delete"}},
},
},
want: "⋮ Edit, Delete",
},
{
name: "select_static no selection",
elem: cardObj{
"tag": "select_static",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
},
},
want: "{Option1 / Option2 ▼}",
},
{
name: "select_static with initial_option",
elem: cardObj{
"tag": "select_static",
"initial_option": "2",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
},
},
want: "{Option1 / ✓Option2}",
},
{
name: "multi_select_static with selected_values",
elem: cardObj{
"tag": "multi_select_static",
"selected_values": []interface{}{"A"},
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptA"}, "value": "A"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptB"}, "value": "B"},
},
},
want: "{✓OptA / OptB}(multi)",
},
{
name: "select_person no options no selection shows placeholder",
elem: cardObj{
"tag": "select_person",
"placeholder": cardObj{"tag": "plain_text", "content": "请选择"},
},
want: "{请选择 ▼}",
},
{
name: "select_person with initial_option synthesizes from ID",
elem: cardObj{
"tag": "select_person",
"initial_option": "fake-open-id-001",
},
want: "{✓fake-open-id-001}",
},
{
name: "multi_select_person with selected_values shows IDs and multi",
elem: cardObj{
"tag": "multi_select_person",
"selected_values": []interface{}{"fake-open-id-001", "fake-open-id-002"},
},
want: "{✓fake-open-id-001 / ✓fake-open-id-002}(multi)",
},
{
name: "multi_select_person no selection shows placeholder",
elem: cardObj{
"tag": "multi_select_person",
"placeholder": cardObj{"tag": "plain_text", "content": "添加人员"},
},
want: "{添加人员 ▼}(multi)",
},
{
name: "input with default_value",
elem: cardObj{
"tag": "input",
"label": cardObj{"tag": "plain_text", "content": "Reason"},
"default_value": "prefilled",
},
want: "Reason: prefilled___",
},
{
name: "input with placeholder",
elem: cardObj{
"tag": "input",
"placeholder": cardObj{"tag": "plain_text", "content": "Type here"},
},
want: "Type here_____",
},
{
name: "date_picker with initial_date",
elem: cardObj{
"tag": "date_picker",
"initial_date": "2026-01-01",
},
want: "📅 2026-01-01",
},
{
name: "date_picker placeholder",
elem: cardObj{
"tag": "date_picker",
"placeholder": cardObj{"tag": "plain_text", "content": "Pick date"},
},
want: "📅 Pick date",
},
{
name: "picker_time with initial_time",
elem: cardObj{
"tag": "picker_time",
"initial_time": "14:30",
},
want: "🕐 14:30",
},
{
name: "checker unchecked",
elem: cardObj{
"tag": "checker",
"text": cardObj{"tag": "plain_text", "content": "Task A"},
},
want: "[ ] Task A",
},
{
name: "checker checked",
elem: cardObj{
"tag": "checker",
"checked": true,
"text": cardObj{"tag": "plain_text", "content": "Task B"},
},
want: "[x] Task B",
},
{
name: "chart with chart_spec",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"title": cardObj{"text": "Sales"},
"type": "bar",
"xField": "month",
"yField": "value",
"data": cardObj{"values": []interface{}{
cardObj{"month": "Jan", "value": float64(10)},
cardObj{"month": "Feb", "value": float64(20)},
}},
},
},
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
},
{
name: "chart with compound xField array",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"title": cardObj{"text": "Sales"},
"type": "bar",
"xField": []interface{}{"month", "category"},
"yField": "value",
"data": cardObj{"values": []interface{}{
cardObj{"month": "Jan", "category": "A", "value": float64(10)},
cardObj{"month": "Feb", "category": "B", "value": float64(20)},
}},
},
},
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
},
{
name: "chart no custom title uses type name",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"type": "pie",
"categoryField": "label",
"valueField": "val",
"data": cardObj{"values": []interface{}{
cardObj{"label": "A", "val": float64(1)},
}},
},
},
want: "📊 Pie chart\nSummary: A:1",
},
{
name: "chart vchart array data format",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"type": "bar",
"xField": "x",
"yField": "y",
"data": []interface{}{
cardObj{"id": "s1", "values": []interface{}{
cardObj{"x": "Jan", "y": float64(5)},
}},
cardObj{"id": "s2", "values": []interface{}{
cardObj{"x": "Feb", "y": float64(8)},
}},
},
},
},
want: "📊 Bar chart\nSummary: Jan:5, Feb:8",
},
{
name: "text_tag",
elem: cardObj{
"tag": "text_tag",
"text": cardObj{"tag": "plain_text", "content": "新功能"},
},
want: "「新功能」",
},
{
name: "avatar with user_id",
elem: cardObj{"tag": "avatar", "user_id": "fake-open-id-001"},
want: "👤(id:fake-open-id-001)",
},
{
name: "avatar no user_id",
elem: cardObj{"tag": "avatar"},
want: "👤",
},
{
name: "select_img no selection",
elem: cardObj{
"tag": "select_img",
"options": []interface{}{
cardObj{"value": "v1", "img_key": "img_k1"},
cardObj{"value": "v2", "img_key": "img_k2"},
},
},
want: "{🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
},
{
name: "select_img with selected",
elem: cardObj{
"tag": "select_img",
"selected_values": []interface{}{"v1"},
"options": []interface{}{
cardObj{"value": "v1", "img_key": "img_k1"},
cardObj{"value": "v2", "img_key": "img_k2"},
},
},
want: "{✓🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
},
{
name: "repeat delegates to elements",
elem: cardObj{
"tag": "repeat",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "item A"},
cardObj{"tag": "markdown", "content": "item B"},
},
},
want: "item A\nitem B",
},
{
name: "audio with file_key",
elem: cardObj{"tag": "audio", "file_key": "file_abc123"},
want: "🎵 Audio(key:file_abc123)",
},
{
name: "audio fallback audio_id",
elem: cardObj{"tag": "audio", "audio_id": "audio_xyz"},
want: "🎵 Audio(key:audio_xyz)",
},
{
name: "video with file_key",
elem: cardObj{"tag": "video", "file_key": "video_abc"},
want: "🎬 Video(key:video_abc)",
},
{
name: "custom_icon returns empty",
elem: cardObj{"tag": "custom_icon", "img_key": "some_key"},
want: "",
},
{
name: "standard_icon returns empty",
elem: cardObj{"tag": "standard_icon", "token": "alarm_outlined"},
want: "",
},
{
name: "button disabled with disabled_tips",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Submit"},
"disabled": true,
"disabled_tips": cardObj{"tag": "plain_text", "content": "Not allowed"},
},
want: "[Submit ✗](tips:\"Not allowed\")",
},
{
name: "button with confirm",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Delete"},
"confirm": cardObj{
"title": cardObj{"tag": "plain_text", "content": "确认"},
"text": cardObj{"tag": "plain_text", "content": "不可撤销"},
},
},
want: "[Delete](confirm:\"确认: 不可撤销\")",
},
{
name: "overflow with url",
elem: cardObj{
"tag": "overflow",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Open"}, "url": "https://example.com"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Copy"}, "value": "copy"},
},
},
want: "⋮ [Open](https://example.com), Copy(copy)",
},
{
name: "select_static with initial_index",
elem: cardObj{
"tag": "select_static",
"initial_index": float64(1),
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "First"}, "value": "a"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Second"}, "value": "b"},
},
},
want: "{First / ✓Second}",
},
{
name: "div text with notation size",
elem: cardObj{
"tag": "div",
"text": cardObj{
"tag": "plain_text",
"content": "小字注释",
"text_size": "notation",
},
},
want: "📝 小字注释",
},
{
name: "form",
elem: cardObj{
"tag": "form",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "fill this"},
},
},
want: "<form>\nfill this\n</form>",
},
{
name: "collapsible_panel collapsed",
elem: cardObj{
"tag": "collapsible_panel",
"expanded": false,
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "inner"},
},
},
want: "▶ Details\n inner\n▲",
},
{
name: "collapsible_panel expanded",
elem: cardObj{
"tag": "collapsible_panel",
"expanded": true,
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "inner"},
},
},
want: "▼ Details\n inner\n▲",
},
{
name: "interactive_container with behaviors",
elem: cardObj{
"tag": "interactive_container",
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com"},
},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "Click here"},
},
},
want: "<clickable url=\"https://example.com\">\nClick here\n</clickable>",
},
{
name: "interactive_container no url",
elem: cardObj{
"tag": "interactive_container",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "No link"},
},
},
want: "<clickable>\nNo link\n</clickable>",
},
{
name: "column_set with buttons → space-joined",
elem: cardObj{
"tag": "column_set",
"columns": []interface{}{
cardObj{"tag": "column", "elements": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "X"}},
}},
cardObj{"tag": "column", "elements": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "Y"}},
}},
},
},
want: "[X] [Y]",
},
{
name: "person",
elem: cardObj{"tag": "person", "user_id": "fake-open-id-002"},
want: "fake-open-id-002",
},
{
name: "unknown tag fallback to content",
elem: cardObj{"tag": "mystery", "content": "mystery content"},
want: "mystery content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.convertElement(tt.elem, 0)
if tt.contains != "" {
if !strings.Contains(got, tt.contains) {
t.Fatalf("convertElement(%s) = %q, want to contain %q", tt.name, got, tt.contains)
}
return
}
if got != tt.want {
t.Fatalf("convertElement(%s) = %q, want %q", tt.name, got, tt.want)
}
})
}
}
func TestUserDslExtractButtonURL(t *testing.T) {
c := &userDslConverter{}
// direct url field wins first
got := c.extractButtonURL(cardObj{
"url": "https://example.com/direct",
"multi_url": cardObj{"url": "https://example.com/multi"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/direct" {
t.Fatalf("direct url = %q, want https://example.com/direct", got)
}
// multi_url.url when no direct url
got = c.extractButtonURL(cardObj{
"multi_url": cardObj{"url": "https://example.com/multi"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/multi" {
t.Fatalf("multi_url = %q, want https://example.com/multi", got)
}
// behaviors default_url as last resort
got = c.extractButtonURL(cardObj{
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/behavior" {
t.Fatalf("behaviors = %q, want https://example.com/behavior", got)
}
// non-open_url behavior is ignored
got = c.extractButtonURL(cardObj{
"behaviors": []interface{}{
cardObj{"type": "callback", "default_url": "https://example.com/callback"},
},
})
if got != "" {
t.Fatalf("non-open_url = %q, want empty", got)
}
// no url anywhere → empty
got = c.extractButtonURL(cardObj{"text": cardObj{"content": "No URL"}})
if got != "" {
t.Fatalf("no url = %q, want empty", got)
}
}
func TestUserDslExtractTableCellValue(t *testing.T) {
c := &userDslConverter{}
// nil
if got := c.extractUserDslTableCellValue(nil); got != "" {
t.Fatalf("nil = %q, want empty", got)
}
// string
if got := c.extractUserDslTableCellValue("hello"); got != "hello" {
t.Fatalf("string = %q, want 'hello'", got)
}
// float64 integer
if got := c.extractUserDslTableCellValue(float64(42)); got != "42" {
t.Fatalf("int float = %q, want '42'", got)
}
// float64 decimal
if got := c.extractUserDslTableCellValue(float64(3.14)); got != "3.14" {
t.Fatalf("float = %q, want '3.14'", got)
}
// []interface{} with text tags → 「text」 format
got := c.extractUserDslTableCellValue([]interface{}{
cardObj{"text": "S2", "color": "blue"},
cardObj{"text": "M1", "color": "red"},
})
if got != "「S2」 「M1」" {
t.Fatalf("tag array = %q, want '「S2」 「M1」'", got)
}
// cardObj with content field
got = c.extractUserDslTableCellValue(cardObj{"content": "cell content"})
if got != "cell content" {
t.Fatalf("cardObj with content = %q, want 'cell content'", got)
}
}
func TestUserDslConvertTable(t *testing.T) {
c := &userDslConverter{}
got := c.convertTable(cardObj{
"columns": []interface{}{
cardObj{"display_name": "客户名称", "name": "customer_name"},
cardObj{"display_name": "规模", "name": "scale"},
cardObj{"display_name": "金额", "name": "arr"},
},
"rows": []interface{}{
cardObj{
"customer_name": "飞书科技",
"scale": []interface{}{cardObj{"text": "S2", "color": "blue"}},
"arr": float64(16800),
},
},
})
want := "| 客户名称 | 规模 | 金额 |\n|------|------|------|\n| 飞书科技 | 「S2」 | 16800 |"
if got != want {
t.Fatalf("convertTable() = %q, want %q", got, want)
}
// no columns → empty
if got := c.convertTable(cardObj{}); got != "" {
t.Fatalf("no columns = %q, want empty", got)
}
}
func TestLarkMdMentionResolution(t *testing.T) {
mentions := []interface{}{
map[string]interface{}{
"key": "@_user_1",
"name": "test-user",
"id": map[string]interface{}{"open_id": "fake-uid-001"},
},
}
// lark_md in div.text — the real Lark event format (C01 case)
card := map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": "Hello <at id=fake-uid-001></at> check this.",
},
},
},
}
dslBytes, _ := json.Marshal(card)
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got := ConvertInteractiveEventContent(string(raw), mentions)
if strings.Contains(got, "<at") {
t.Fatalf("div.text lark_md: raw <at> tag not resolved, got: %s", got)
}
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("div.text lark_md: @id not in output, got: %s", got)
}
// lark_md in note.elements (C02 case)
card = map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "note",
"elements": []interface{}{
map[string]interface{}{
"tag": "lark_md",
"content": "Note: <at id=fake-uid-001></at> check.",
},
},
},
},
}
dslBytes, _ = json.Marshal(card)
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got = ConvertInteractiveEventContent(string(raw), mentions)
if strings.Contains(got, "<at") {
t.Fatalf("note lark_md: raw <at> tag not resolved, got: %s", got)
}
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("note lark_md: @id not in output, got: %s", got)
}
// mention_key resolution via mentions map
card = map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": `Hi <at mention_key="@_user_1">n</at> done.`,
},
},
},
}
dslBytes, _ = json.Marshal(card)
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got = ConvertInteractiveEventContent(string(raw), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("div.text lark_md mention_key: want @test-user(fake-uid-001), got: %s", got)
}
}
func TestConvertUserDslCardEndToEnd(t *testing.T) {
// user-2.ts format — matches structure of docs/user-dsl/user-example-2.json
schema2JSON := `{
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "飞书卡片组件展示"},
"template": "blue"
},
"body": {
"elements": [
{"tag": "markdown", "content": "### 基础文本"},
{"tag": "hr"},
{
"tag": "img",
"img_key": "img_v3_02122_abc",
"alt": {"tag": "plain_text", "content": "示例图片"}
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "主要按钮"},
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
},
{
"tag": "table",
"columns": [
{"display_name": "名称", "name": "name"},
{"display_name": "数值", "name": "value"}
],
"rows": [
{"name": "项目A", "value": 100},
{"name": "项目B", "value": 200}
]
}
]
}
}`
got := convertUserDslCard(schema2JSON, nil)
if !strings.HasPrefix(got, `<card title="飞书卡片组件展示">`) {
t.Fatalf("e2e schema2: missing card title prefix, got: %s", got)
}
if !strings.Contains(got, "### 基础文本") {
t.Fatal("e2e schema2: missing markdown content")
}
if !strings.Contains(got, "---") {
t.Fatal("e2e schema2: missing hr")
}
if !strings.Contains(got, "🖼️ 示例图片(img_key:img_v3_02122_abc)") {
t.Fatalf("e2e schema2: missing image, got: %s", got)
}
if !strings.Contains(got, "[主要按钮](https://example.com)") {
t.Fatalf("e2e schema2: missing button, got: %s", got)
}
if !strings.Contains(got, "| 名称 | 数值 |") {
t.Fatal("e2e schema2: missing table header")
}
if !strings.Contains(got, "| 项目A | 100 |") {
t.Fatalf("e2e schema2: missing table row, got: %s", got)
}
if !strings.HasSuffix(got, "</card>") {
t.Fatalf("e2e schema2: missing </card> suffix, got: %s", got)
}
// user-1.ts format
schema1JSON := `{
"i18n_header": {
"zh_cn": {
"title": {"tag": "plain_text", "content": "Schema1 卡片"},
"template": "blue"
}
},
"elements": [
{"tag": "markdown", "content": "Hello **World**"},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "跳转"},
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
}
]
}
]
}`
got = convertUserDslCard(schema1JSON, nil)
if !strings.HasPrefix(got, `<card title="Schema1 卡片">`) {
t.Fatalf("e2e schema1: missing card title, got: %s", got)
}
if !strings.Contains(got, "Hello **World**") {
t.Fatal("e2e schema1: missing markdown")
}
if !strings.Contains(got, "[跳转](https://example.com)") {
t.Fatalf("e2e schema1: missing button, got: %s", got)
}
}

View File

@@ -122,7 +122,7 @@ func TestExtractPostBlocksText(t *testing.T) {
}
got := extractPostBlocksText(blocks)
want := "hello @Alice [docs](https://example.com)\n[Image: img_123]"
want := "hello @Alice [docs](https://example.com)\n![Image](img_123)"
if got != want {
t.Fatalf("extractPostBlocksText() = %q, want %q", got, want)
}

View File

@@ -39,16 +39,16 @@ func (postConverter) Convert(ctx *ConvertContext) string {
if title, _ := body["title"].(string); title != "" {
parts = append(parts, title)
}
if blocks, _ := body["content"].([]interface{}); len(blocks) > 0 {
for _, para := range blocks {
elems, _ := para.([]interface{})
var line strings.Builder
for _, el := range elems {
elem, _ := el.(map[string]interface{})
line.WriteString(renderPostElem(elem))
}
parts = append(parts, line.String())
// Prefer content_v2 blocks; fallback to content blocks
blocks := selectContentBlocks(body)
for _, para := range blocks {
elems, _ := para.([]interface{})
var line strings.Builder
for _, el := range elems {
elem, _ := el.(map[string]interface{})
line.WriteString(renderPostElem(elem))
}
parts = append(parts, line.String())
}
result := strings.TrimSpace(strings.Join(parts, "\n"))
@@ -58,6 +58,17 @@ func (postConverter) Convert(ctx *ConvertContext) string {
return ResolveMentionKeys(result, ctx.MentionMap)
}
// selectContentBlocks returns content_v2 blocks when present and non-empty;
// otherwise falls back to content blocks. This implements the content_v2
// priority rule for post messages.
func selectContentBlocks(body map[string]interface{}) []interface{} {
if v2, ok := body["content_v2"].([]interface{}); ok && len(v2) > 0 {
return v2
}
blocks, _ := body["content"].([]interface{})
return blocks
}
func unwrapPostLocale(parsed map[string]interface{}) map[string]interface{} {
if _, ok := parsed["content"]; ok {
return parsed
@@ -114,10 +125,14 @@ func renderPostElem(el map[string]interface{}) string {
var rendered string
switch {
case userId == "@_all" || userId == "all":
rendered = "@all"
rendered = `<at user_id="all"></at>`
default:
if name, _ := el["user_name"].(string); name != "" {
rendered = "@" + name
if userId != "" && strings.HasPrefix(userId, "ou") {
rendered = fmt.Sprintf(`<at user_id="%s">%s</at>`, userId, name)
} else {
rendered = "@" + name
}
} else {
rendered = "@" + userId
}
@@ -138,7 +153,7 @@ func renderPostElem(el map[string]interface{}) string {
case "img":
key, _ := el["image_key"].(string)
if key != "" {
return fmt.Sprintf("[Image: %s]", key)
return fmt.Sprintf("![Image](%s)", key)
}
return "[Image]"
case "media":

View File

@@ -93,9 +93,13 @@ func TestRenderPostElem(t *testing.T) {
}{
{name: "text", el: map[string]interface{}{"tag": "text", "text": "hello"}, want: "hello"},
{name: "link", el: map[string]interface{}{"tag": "a", "text": "doc", "href": "https://example.com"}, want: "[doc](https://example.com)"},
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: "@all"},
{name: "mention user", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: "[Image: img_123]"},
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: `<at user_id="all"></at>`},
{name: "mention user with id", el: map[string]interface{}{"tag": "at", "user_id": "ou_user_1", "user_name": "Alice"}, want: `<at user_id="ou_user_1">Alice</at>`},
{name: "mention user name only", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
{name: "mention user id only", el: map[string]interface{}{"tag": "at", "user_id": "@_user_1"}, want: "@@_user_1"},
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: "![Image](img_123)"},
{name: "image no key", el: map[string]interface{}{"tag": "img"}, want: "[Image]"},
{name: "md text", el: map[string]interface{}{"tag": "md", "text": "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}, want: "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"},
{name: "media", el: map[string]interface{}{"tag": "media", "file_key": "file_123"}, want: "[Media: file_123]"},
{name: "code block", el: map[string]interface{}{"tag": "code_block", "language": "go", "text": "fmt.Println(1)"}, want: "\n```go\nfmt.Println(1)\n```\n"},
{name: "hr", el: map[string]interface{}{"tag": "hr"}, want: "\n---\n"},
@@ -144,3 +148,87 @@ func TestRenderPostElemEmotionStyleMd(t *testing.T) {
})
}
}
func TestSelectContentBlocks(t *testing.T) {
tests := []struct {
name string
body map[string]interface{}
want int
}{
{
name: "content_v2 present and non-empty",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": []interface{}{[]interface{}{map[string]interface{}{"tag": "md", "text": "new"}}},
},
want: 1,
},
{
name: "content_v2 empty array",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": []interface{}{},
},
want: 1,
},
{
name: "content_v2 nil",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
},
want: 1,
},
{
name: "content_v2 wrong type",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": "not_an_array",
},
want: 1,
},
{
name: "both missing",
body: map[string]interface{}{},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := selectContentBlocks(tt.body)
if len(got) != tt.want {
t.Fatalf("selectContentBlocks() len = %d, want %d", len(got), tt.want)
}
})
}
}
func TestPostConverterConvertContentV2(t *testing.T) {
// AC-M1-H1: content_v2 present → use content_v2 blocks (md passthrough)
ctx := &ConvertContext{
RawContent: `{"content_v2":[[{"tag":"md","text":"##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}]],"content":[[{"tag":"text","text":"old path"}]]}`,
}
want := "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"
if got := (postConverter{}).Convert(ctx); got != want {
t.Fatalf("postConverter.Convert(content_v2) = %q, want %q", got, want)
}
// AC-M1-H2: no content_v2 → use content blocks with new at/img format
ctx2 := &ConvertContext{
RawContent: `{"content":[[{"tag":"at","user_id":"ou_xxx","user_name":"Bob"},{"tag":"text","text":" "},{"tag":"img","image_key":"img_123"}]]}`,
Mentions: []interface{}{map[string]interface{}{"key": "ou_xxx", "id": "ou_bob", "name": "Bob"}},
}
want2 := `<at user_id="ou_xxx">Bob</at> ![Image](img_123)`
if got := (postConverter{}).Convert(ctx2); got != want2 {
t.Fatalf("postConverter.Convert(content) = %q, want %q", got, want2)
}
// AC-M1-E1: content_v2 empty → fallback to content
ctx3 := &ConvertContext{
RawContent: `{"content_v2":[],"content":[[{"tag":"text","text":"fallback path"}]]}`,
}
want3 := "fallback path"
if got := (postConverter{}).Convert(ctx3); got != want3 {
t.Fatalf("postConverter.Convert(empty content_v2) = %q, want %q", got, want3)
}
}

View File

@@ -0,0 +1,385 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// batchCreateKR represents a key result in the batch create input.
type batchCreateKR struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
}
// batchCreateObjective represents an objective in the batch create input.
type batchCreateObjective struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
KRs []batchCreateKR `json:"krs,omitempty"`
}
// createdObjective tracks a created objective and its KR IDs for output.
// KRs are automatically deleted by the backend when the objective is deleted (no need to delete them separately during rollback).
type createdObjective struct {
ObjectiveID string
KRIDs []string // for output response only, not used in rollback
}
// parseBatchCreateInput parses and validates the JSON input.
func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
var objectives []batchCreateObjective
if err := json.Unmarshal([]byte(input), &objectives); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must be valid JSON array: %s", err).WithParam("--input").WithCause(err)
}
if len(objectives) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must contain at least one objective").WithParam("--input")
}
for i, obj := range objectives {
if strings.TrimSpace(obj.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].text is required and cannot be empty", i).WithParam("--input")
}
for j, kr := range obj.KRs {
if strings.TrimSpace(kr.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].krs[%d].text is required and cannot be empty", i, j).WithParam("--input")
}
}
}
return objectives, nil
}
// buildContentBlock converts text and mentions to a ContentBlock.
func buildContentBlock(text string, mentions []string) *ContentBlock {
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
// Add text element
textElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: &text,
},
}
elements = append(elements, textElem)
// Add mention elements
for _, mention := range mentions {
mentionElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: &mention,
},
}
elements = append(elements, mentionElem)
}
return &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: elements,
},
},
},
}
}
// createObjective calls the API to create an objective.
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
content := buildContentBlock(obj.Text, obj.Mention)
body := map[string]interface{}{
"content": content,
}
queryParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to create objective")
}
objectiveID, ok := data["objective_id"].(string)
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "create objective response missing objective_id")
}
return objectiveID, nil
}
// createKR calls the API to create a key result.
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
content := buildContentBlock(kr.Text, kr.Mention)
body := map[string]interface{}{
"content": content,
}
queryParams := map[string]interface{}{
"objective_id": objectiveID,
"user_id_type": userIDType,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to create key result")
}
krID, ok := data["key_result_id"].(string)
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "create key result response missing key_result_id")
}
return krID, nil
}
// deleteObjective deletes an objective (used for rollback).
func deleteObjective(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) error {
queryParams := map[string]interface{}{
"objective_id": objectiveID,
"yes": true,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s", objectiveID)
_, err := runtime.CallAPITyped("DELETE", path, queryParams, nil)
if err != nil {
return wrapOkrNetworkErr(err, "failed to delete objective %s during rollback", objectiveID)
}
return nil
}
// rollback deletes created objectives in reverse order.
// KRs are automatically deleted by the backend when the objective is deleted.
func rollback(ctx context.Context, runtime *common.RuntimeContext, created []createdObjective) []error {
var errsList []error
// Iterate in reverse order
for i := len(created) - 1; i >= 0; i-- {
obj := created[i]
// Delete the objective (backend automatically deletes its KRs)
if err := deleteObjective(ctx, runtime, obj.ObjectiveID); err != nil {
//nolint:forbidigo // intermediate wrap for rollback error collection; final error is typed via buildRollbackError
errsList = append(errsList, fmt.Errorf("objective %s: %w", obj.ObjectiveID, err))
}
// Rate limiting between deletions
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
}
return errsList
}
// OKRBatchCreate batch creates objectives and their key results.
var OKRBatchCreate = common.Shortcut{
Service: "okr",
Command: "+batch-create",
Description: "Batch create OKR objectives and key results with rollback on failure",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
{Name: "input", Desc: "JSON array of objectives: [{\"text\":\"...\",\"mention\":[\"...\"],\"krs\":[{\"text\":\"...\",\"mention\":[\"...\"]}]}]", Input: []string{common.File, common.Stdin}, Required: true},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
input := runtime.Str("input")
if err := common.RejectDangerousCharsTyped("--input", input); err != nil {
return err
}
if _, err := parseBatchCreateInput(input); err != nil {
return err
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
objectives, _ := parseBatchCreateInput(runtime.Str("input"))
apis := common.NewDryRunAPI()
for i, obj := range objectives {
// Objective creation
objContent := buildContentBlock(obj.Text, obj.Mention)
objBody := map[string]interface{}{
"content": objContent,
}
objParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
}
objPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
POST(objPath).
Params(objParams).
Body(objBody).
Desc(fmt.Sprintf("Create objective[%d]: %s", i, obj.Text))
// KR creations
for j, kr := range obj.KRs {
krContent := buildContentBlock(kr.Text, kr.Mention)
krBody := map[string]interface{}{
"content": krContent,
}
krParams := map[string]interface{}{
"objective_id": "<objective_id_from_previous_call>",
"user_id_type": userIDType,
}
krPath := "/open-apis/okr/v2/objectives/<objective_id>/key_results"
apis = apis.
POST(krPath).
Params(krParams).
Body(krBody).
Desc(fmt.Sprintf("Create objective[%d].krs[%d]: %s", i, j, kr.Text))
}
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
objectives, err := parseBatchCreateInput(runtime.Str("input"))
if err != nil {
return err
}
var created []createdObjective
for i, obj := range objectives {
// Rate limiting between objectives
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
// Create objective
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, obj)
if err != nil {
if len(created) == 0 {
return err
}
rollbackErrs := rollback(ctx, runtime, created)
return buildRollbackError(err, rollbackErrs, created)
}
createdObj := createdObjective{
ObjectiveID: objectiveID,
}
// Create KRs
for j, kr := range obj.KRs {
// Rate limiting between KRs
if j > 0 {
time.Sleep(500 * time.Millisecond)
}
krID, err := createKR(ctx, runtime, objectiveID, userIDType, kr)
if err != nil {
created = append(created, createdObj)
rollbackErrs := rollback(ctx, runtime, created)
return buildRollbackError(err, rollbackErrs, created)
}
createdObj.KRIDs = append(createdObj.KRIDs, krID)
}
created = append(created, createdObj)
}
// Build response
respCreated := make([]map[string]interface{}, 0, len(created))
for _, obj := range created {
respCreated = append(respCreated, map[string]interface{}{
"objective_id": obj.ObjectiveID,
"krs": obj.KRIDs,
})
}
result := map[string]interface{}{
"ok": true,
"data": map[string]interface{}{"created": respCreated},
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully created %d objective(s)\n", len(created))
for i, obj := range created {
fmt.Fprintf(w, "Objective[%d] ID: %s (%d KR(s))\n", i, obj.ObjectiveID, len(obj.KRIDs))
for j, krID := range obj.KRIDs {
fmt.Fprintf(w, " KR[%d] ID: %s\n", j, krID)
}
}
})
return nil
},
}
// buildRollbackError constructs an error that includes both the original failure
// and any rollback failures, with a list of residual IDs that could not be cleaned up.
// KRs are automatically deleted by the backend when the objective is deleted, so we only
// need to track objective IDs for residual cleanup.
func buildRollbackError(originalErr error, rollbackErrs []error, created []createdObjective) error {
var residualIDs []string
// Only collect residual IDs when rollback had failures
// If rollback succeeded (len(rollbackErrs) == 0), all objectives were deleted
if len(rollbackErrs) > 0 {
for _, obj := range created {
residualIDs = append(residualIDs, fmt.Sprintf("objective:%s", obj.ObjectiveID))
}
}
msg := fmt.Sprintf("batch create failed, rolling back: %v", originalErr)
if len(rollbackErrs) > 0 {
var rollbackMsgs []string
for _, e := range rollbackErrs {
rollbackMsgs = append(rollbackMsgs, e.Error())
}
msg += fmt.Sprintf("; rollback also had %d failure(s): %s", len(rollbackErrs), strings.Join(rollbackMsgs, "; "))
}
if len(residualIDs) > 0 {
msg += fmt.Sprintf("; residual objectives that may need manual cleanup (KRs auto-deleted with objective): %s", strings.Join(residualIDs, ", "))
}
// Preserve the original error's type information if it's already a typed error
if prob, ok := errs.ProblemOf(originalErr); ok {
switch prob.Category {
case errs.CategoryAPI:
return errs.NewAPIError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryNetwork:
return errs.NewNetworkError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryValidation:
return errs.NewValidationError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryInternal:
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
default:
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
}
}
return errs.NewInternalError(errs.SubtypeUnknown, "%s", msg).WithCause(originalErr)
}

View File

@@ -0,0 +1,593 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-batch-create",
AppSecret: "secret-okr-batch-create",
Brand: core.BrandFeishu,
}
}
func runBatchCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRBatchCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
const validBatchCreateInput = `[
{"text":"Objective 1","mention":["ou_123"],"krs":[{"text":"KR 1.1","mention":["ou_456"]}]},
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
]`
// --- Validate tests ---
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--input", validBatchCreateInput,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "abc",
"--input", validBatchCreateInput,
})
if err == nil {
t.Fatal("expected error for invalid --cycle-id")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--cycle-id" {
t.Fatalf("expected param --cycle-id, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_MissingInput(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "input") {
t.Fatalf("expected --input required error, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidInputJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --input JSON")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_EmptyInputArray(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", "[]",
})
if err == nil {
t.Fatal("expected error for empty --input array")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_EmptyObjectiveText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for empty objective text")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "objective[0].text") {
t.Fatalf("expected error to mention objective[0].text, got: %v", err)
}
}
func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":""}]}]`,
})
if err == nil {
t.Fatal("expected error for empty KR text")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "objective[0].krs[0].text") {
t.Fatalf("expected error to mention objective[0].krs[0].text, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
"--user-id-type", "invalid",
})
if err == nil {
t.Fatal("expected error for invalid --user-id-type")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--user-id-type" {
t.Fatalf("expected param --user-id-type, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_Valid(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "200",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "101",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/101/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "201",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/101/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "202",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- DryRun tests ---
func TestBatchCreateDryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objective creation API path, got: %s", output)
}
if !strings.Contains(output, "POST") {
t.Fatalf("dry-run output should contain POST method, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/") || !strings.Contains(output, "/key_results") {
t.Fatalf("dry-run output should contain KR creation API path, got: %s", output)
}
// Verify content is in the body
if !strings.Contains(output, "Objective 1") {
t.Fatalf("dry-run output should contain objective text, got: %s", output)
}
if !strings.Contains(output, "KR 1.1") {
t.Fatalf("dry-run output should contain KR text, got: %s", output)
}
}
// --- Execute tests ---
func TestBatchCreateExecute_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "200",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
ok, _ := data["ok"].(bool)
if !ok {
t.Fatal("expected ok=true in output")
}
dataField, _ := data["data"].(map[string]interface{})
created, _ := dataField["created"].([]interface{})
if len(created) != 1 {
t.Fatalf("expected 1 created objective, got %d", len(created))
}
obj, _ := created[0].(map[string]interface{})
if obj["objective_id"] != "100" {
t.Fatalf("expected objective_id=100, got %v", obj["objective_id"])
}
krs, _ := obj["krs"].([]interface{})
if len(krs) != 1 || krs[0] != "200" {
t.Fatalf("expected krs=[200], got %v", krs)
}
}
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1"}]`,
})
if err == nil {
t.Fatal("expected error for API failure")
}
// Should be a typed error from the API
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != "api" {
t.Fatalf("expected api category, got %q", prob.Category)
}
}
func TestBatchCreateExecute_APIErrorOnKR_TriggersRollback(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
// First objective creation succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
// KR creation fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
// Rollback: delete the created objective
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/okr/v2/objectives/100",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for KR creation failure")
}
// Error should mention rollback
if !strings.Contains(err.Error(), "rolling back") && !strings.Contains(err.Error(), "rollback") {
t.Fatalf("expected error to mention rollback, got: %v", err)
}
// Assert typed error metadata
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected api category (preserved from original error), got %q", prob.Category)
}
// Assert cause preservation
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to wrap APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("expected errors.Is to find the wrapped APIError")
}
}
func TestBatchCreateExecute_RollbackDeleteFails(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
// Objective creation succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
// KR creation fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
// Rollback delete also fails
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/okr/v2/objectives/100",
Status: 500,
Body: map[string]interface{}{
"code": 9999999,
"msg": "internal error",
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for KR creation failure")
}
// Error should mention residual resources
if !strings.Contains(err.Error(), "residual") && !strings.Contains(err.Error(), "manual cleanup") {
t.Fatalf("expected error to mention residual resources, got: %v", err)
}
if !strings.Contains(err.Error(), "objective:100") {
t.Fatalf("expected error to list residual objective ID, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseBatchCreateInput_Valid(t *testing.T) {
t.Parallel()
input := `[{"text":"Obj 1","mention":["ou_123"],"krs":[{"text":"KR 1","mention":["ou_456"]}]}]`
objs, err := parseBatchCreateInput(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(objs) != 1 {
t.Fatalf("expected 1 objective, got %d", len(objs))
}
if objs[0].Text != "Obj 1" {
t.Fatalf("expected text 'Obj 1', got %q", objs[0].Text)
}
if len(objs[0].Mention) != 1 || objs[0].Mention[0] != "ou_123" {
t.Fatalf("expected mention ['ou_123'], got %v", objs[0].Mention)
}
if len(objs[0].KRs) != 1 {
t.Fatalf("expected 1 KR, got %d", len(objs[0].KRs))
}
if objs[0].KRs[0].Text != "KR 1" {
t.Fatalf("expected KR text 'KR 1', got %q", objs[0].KRs[0].Text)
}
}
func TestBuildContentBlock(t *testing.T) {
t.Parallel()
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
block := cb.Blocks[0]
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
t.Fatalf("expected paragraph block type")
}
if block.Paragraph == nil {
t.Fatal("expected non-nil paragraph")
}
// Should have 3 elements: 1 text + 2 mentions
if len(block.Paragraph.Elements) != 3 {
t.Fatalf("expected 3 paragraph elements, got %d", len(block.Paragraph.Elements))
}
// First element should be textRun
if block.Paragraph.Elements[0].ParagraphElementType == nil ||
*block.Paragraph.Elements[0].ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected first element to be textRun")
}
if block.Paragraph.Elements[0].TextRun == nil || *block.Paragraph.Elements[0].TextRun.Text != "Test text" {
t.Fatalf("expected text 'Test text', got %v", block.Paragraph.Elements[0].TextRun)
}
// Second and third should be mentions
for i := 1; i <= 2; i++ {
if block.Paragraph.Elements[i].ParagraphElementType == nil ||
*block.Paragraph.Elements[i].ParagraphElementType != ParagraphElementTypeMention {
t.Fatalf("expected element %d to be mention", i)
}
}
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"fmt"
"io"
"math"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// parseIndicatorValue parses and validates the indicator value.
func parseIndicatorValue(valueStr string) (float64, error) {
value, err := strconv.ParseFloat(valueStr, 64)
if err != nil {
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value").WithCause(err)
}
if math.IsNaN(value) || math.IsInf(value, 0) || value < -99999999999 || value > 99999999999 {
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value")
}
return value, nil
}
// fetchIndicatorID fetches the indicator ID for an objective or key result.
// The indicators.list API returns a single indicator object (not a list),
// which always exists (may be a default empty indicator).
func fetchIndicatorID(ctx context.Context, runtime *common.RuntimeContext, level string, id string) (string, error) {
var path string
var params map[string]interface{}
if level == "objective" {
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
params = map[string]interface{}{"page_size": 100}
} else {
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
params = map[string]interface{}{"page_size": 100}
}
data, err := runtime.CallAPITyped("GET", path, params, nil)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to fetch indicators")
}
// Parse response to get indicator ID
// Response format: {"indicator": {"id": "...", ...}} (single object, not a list)
indicator, ok := data["indicator"].(map[string]interface{})
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator field not found in response")
}
indicatorID, ok := indicator["id"].(string)
if !ok || indicatorID == "" {
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator ID not found or empty")
}
return indicatorID, nil
}
// OKRIndicatorUpdate updates the current value of an indicator for an objective or key result.
var OKRIndicatorUpdate = common.Shortcut{
Service: "okr",
Command: "+indicator-update",
Description: "Update the indicator current value for an objective or key result",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to update: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
{Name: "id", Desc: "objective or key result ID (int64), Required."},
{Name: "value", Desc: "new current value for the indicator (number), Required."},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
}
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
id := runtime.Str("id")
if id == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id is required").WithParam("--id")
}
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id must be a valid int64").WithParam("--id")
}
if runtime.Str("value") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--value is required").WithParam("--value")
}
if _, err := parseIndicatorValue(runtime.Str("value")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
id := runtime.Str("id")
value, _ := parseIndicatorValue(runtime.Str("value"))
apis := common.NewDryRunAPI()
var listPath string
if level == "objective" {
listPath = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
} else {
listPath = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
}
// First API: fetch indicator list
apis = apis.
GET(listPath).
Params(map[string]interface{}{"page_size": 100}).
Desc(fmt.Sprintf("Fetch indicators for the %s to get indicator ID", level))
// Second API: patch indicator value
patchPath := "/open-apis/okr/v2/indicators/:indicator_id"
patchBody := map[string]interface{}{
"current_value": value,
}
apis = apis.
PATCH(patchPath).
Body(patchBody).
Set("indicator_id", "<indicator_id_from_list>").
Desc("Update indicator current value")
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
id := runtime.Str("id")
value, err := parseIndicatorValue(runtime.Str("value"))
if err != nil {
return err
}
// Step 1: Fetch indicator ID
indicatorID, err := fetchIndicatorID(ctx, runtime, level, id)
if err != nil {
return err
}
// Step 2: Update indicator value
patchPath := fmt.Sprintf("/open-apis/okr/v2/indicators/%s", indicatorID)
patchBody := map[string]interface{}{
"current_value": value,
}
_, err = runtime.CallAPITyped("PATCH", patchPath, nil, patchBody)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update indicator value")
}
// Build response
result := map[string]interface{}{
"indicator_id": indicatorID,
"current_value": value,
"level": level,
"target_id": id,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Indicator [%s]\n", indicatorID)
fmt.Fprintf(w, " Level: %s\n", level)
fmt.Fprintf(w, " Target ID: %s\n", id)
fmt.Fprintf(w, " Current Value: %v\n", value)
})
return nil
},
}

View File

@@ -0,0 +1,391 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func indicatorUpdateTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-indicator-update",
AppSecret: "secret-okr-indicator-update",
Brand: core.BrandFeishu,
}
}
func runIndicatorUpdateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRIndicatorUpdate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestIndicatorUpdateValidate_MissingLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--id", "123",
"--value", "50",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "invalid",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for invalid level")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_MissingID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--value", "50",
})
if err == nil || !strings.Contains(err.Error(), "id") {
t.Fatalf("expected --id required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "not-a-number",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for invalid id")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--id" {
t.Fatalf("expected param --id, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_MissingValue(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
})
if err == nil || !strings.Contains(err.Error(), "value") {
t.Fatalf("expected --value required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidValue(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "not-a-number",
})
if err == nil {
t.Fatal("expected error for invalid value")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--value" {
t.Fatalf("expected param --value, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_Valid(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "75.5",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- Execute tests ---
func TestIndicatorUpdateExecute_Objectives_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
BodyFilter: func(body []byte) bool {
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return false
}
val, ok := data["current_value"].(float64)
return ok && val == 75.5
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "75.5",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIndicatorUpdateExecute_KeyResults_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/key_results/456/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-789",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-789",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "key-result",
"--id", "456",
"--value", "100",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIndicatorUpdateExecute_FetchAPIError(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators - API error
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 9999,
"msg": "fetch error",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for fetch API failure")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("errors.Is should find the APIError in the chain")
}
}
func TestIndicatorUpdateExecute_PatchAPIError(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator - API error
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 9999,
"msg": "patch error",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for patch API failure")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("errors.Is should find the APIError in the chain")
}
}
// --- parseIndicatorValue tests ---
func TestParseIndicatorValue_Valid(t *testing.T) {
t.Parallel()
tests := []string{"0", "100", "75.5", "-10", "0.001", "99999999999"}
for _, v := range tests {
result, err := parseIndicatorValue(v)
if err != nil {
t.Fatalf("expected no error for %q, got: %v", v, err)
}
_ = result
}
}
func TestParseIndicatorValue_Invalid(t *testing.T) {
t.Parallel()
tests := []string{"", "abc", "1e100000", "100000000000"}
for _, v := range tests {
_, err := parseIndicatorValue(v)
if err == nil {
t.Fatalf("expected error for %q", v)
}
}
}

View File

@@ -0,0 +1,448 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// reorderItem is the interface for items that have an ID.
type reorderItem interface {
GetID() string
}
// reorderOp represents a single reorder operation.
type reorderOp struct {
ID string `json:"id"`
Position int32 `json:"position"`
}
// parseReorderOps parses and validates the --ops JSON array.
func parseReorderOps(opsStr string) ([]reorderOp, error) {
var ops []reorderOp
if err := json.Unmarshal([]byte(opsStr), &ops); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must be valid JSON array: %s", err).WithParam("--ops").WithCause(err)
}
if len(ops) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must contain at least one operation").WithParam("--ops")
}
seen := make(map[string]bool)
seenPos := make(map[int32]bool)
for i, op := range ops {
if strings.TrimSpace(op.ID) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].id is required and cannot be empty", i).WithParam("--ops")
}
if op.Position <= 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].position must be a positive integer", i).WithParam("--ops")
}
if seen[op.ID] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --ops", op.ID).WithParam("--ops")
}
if seenPos[op.Position] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate position %d in --ops", op.Position).WithParam("--ops")
}
seen[op.ID] = true
seenPos[op.Position] = true
}
return ops, nil
}
// fetchObjectives fetches all objectives in a cycle.
func fetchObjectives(ctx context.Context, runtime *common.RuntimeContext, cycleID string) ([]Objective, error) {
queryParams := map[string]interface{}{"page_size": "100"}
var objectives []Objective
page := 0
for {
if page > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
page++
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return nil, wrapOkrNetworkErr(err, "failed to fetch objectives")
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var obj Objective
if err := json.Unmarshal(raw, &obj); err != nil {
continue
}
objectives = append(objectives, obj)
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
queryParams["page_token"] = pageToken
}
// Sort objectives by position
sort.Slice(objectives, func(i, j int) bool {
pi := int32(0)
if objectives[i].Position != nil {
pi = *objectives[i].Position
}
pj := int32(0)
if objectives[j].Position != nil {
pj = *objectives[j].Position
}
return pi < pj
})
return objectives, nil
}
// fetchKeyResults fetches all key results for an objective.
func fetchKeyResults(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) ([]KeyResult, error) {
queryParams := map[string]interface{}{"page_size": "100"}
var keyResults []KeyResult
page := 0
for {
if page > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
page++
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return nil, wrapOkrNetworkErr(err, "failed to fetch key results")
}
itemsRaw, _ := data["items"].([]interface{})
for _, item := range itemsRaw {
raw, err := json.Marshal(item)
if err != nil {
continue
}
var kr KeyResult
if err := json.Unmarshal(raw, &kr); err != nil {
continue
}
keyResults = append(keyResults, kr)
}
hasMore, pageToken := common.PaginationMeta(data)
if !hasMore || pageToken == "" {
break
}
queryParams["page_token"] = pageToken
}
// Sort key results by position
sort.Slice(keyResults, func(i, j int) bool {
pi := int32(0)
if keyResults[i].Position != nil {
pi = *keyResults[i].Position
}
pj := int32(0)
if keyResults[j].Position != nil {
pj = *keyResults[j].Position
}
return pi < pj
})
return keyResults, nil
}
// buildReorderedIDs builds the complete ordered ID list from current items and reorder ops.
// Positions are treated as 1-indexed placement keys stored in a map (safe for large values).
// Items are first placed at user-specified positions, remaining items fill empty slots
// in original order starting from position 1, and final output is sorted by position.
func buildReorderedIDs[T reorderItem](items []T, ops []reorderOp, total int) ([]string, error) {
// Create a map of ID to current position
idToPos := make(map[string]int)
for i, item := range items {
idToPos[item.GetID()] = i
}
// Validate all ops IDs exist
for _, op := range ops {
if _, ok := idToPos[op.ID]; !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "id %q not found in current list", op.ID).WithParam("--ops")
}
}
// Use map to store position -> ID (1-indexed, safe for large position values)
posToID := make(map[int]string)
used := make(map[string]bool)
for _, op := range ops {
posToID[int(op.Position)] = op.ID
used[op.ID] = true
}
// Collect unused items in original order
var unused []string
for _, item := range items {
id := item.GetID()
if !used[id] {
unused = append(unused, id)
}
}
// Fill empty slots starting from position 1, in original order
unusedIdx := 0
for pos := 1; unusedIdx < len(unused); pos++ {
if _, occupied := posToID[pos]; !occupied {
posToID[pos] = unused[unusedIdx]
unusedIdx++
}
}
// Collect all positions, sort them, and build result in position order
positions := make([]int, 0, len(posToID))
for pos := range posToID {
positions = append(positions, pos)
}
sort.Ints(positions)
result := make([]string, 0, len(positions))
for _, pos := range positions {
result = append(result, posToID[pos])
}
return result, nil
}
// GetID implements the interface for Objective.
func (o Objective) GetID() string { return o.ID }
// GetID implements the interface for KeyResult.
func (k KeyResult) GetID() string { return k.ID }
// OKRReorder adjusts the position of objectives or key results.
var OKRReorder = common.Shortcut{
Service: "okr",
Command: "+reorder",
Description: "Adjust the position (order) of OKR objectives or key results",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to reorder: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
{Name: "cycle-id", Desc: "OKR cycle ID (int64), Required."},
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
{Name: "ops", Desc: "JSON array of reorder operations: [{\"id\":\"...\",\"position\":1}], Required.", Input: []string{common.File, common.Stdin}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if strings.TrimSpace(level) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
}
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
cycleID := runtime.Str("cycle-id")
if strings.TrimSpace(cycleID) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required").WithParam("--cycle-id")
}
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
if level == "key-result" {
objID := runtime.Str("objective-id")
if objID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
}
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
}
}
opsStr := runtime.Str("ops")
if strings.TrimSpace(opsStr) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops is required").WithParam("--ops")
}
if err := common.RejectDangerousCharsTyped("--ops", opsStr); err != nil {
return err
}
if _, err := parseReorderOps(opsStr); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, _ := parseReorderOps(runtime.Str("ops"))
apis := common.NewDryRunAPI()
if level == "objective" {
// First fetch objectives
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all objectives in the cycle to determine current order")
// Then reorder
reorderParams := map[string]interface{}{
"cycle_id": cycleID,
}
// Build sample body with placeholder IDs
objectiveIDs := make([]string, 0, len(ops))
for _, op := range ops {
objectiveIDs = append(objectiveIDs, op.ID)
}
reorderBody := map[string]interface{}{
"objective_ids": objectiveIDs,
}
reorderPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
apis = apis.
PUT(reorderPath).
Params(reorderParams).
Body(reorderBody).
Desc("Update objective positions (full list sent, not just changes)")
} else {
// key-result level
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all key results for the objective to determine current order")
reorderParams := map[string]interface{}{
"objective_id": objectiveID,
}
// Build sample body with placeholder IDs
keyResultIDs := make([]string, 0, len(ops))
for _, op := range ops {
keyResultIDs = append(keyResultIDs, op.ID)
}
reorderBody := map[string]interface{}{
"key_result_ids": keyResultIDs,
}
reorderPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
apis = apis.
PUT(reorderPath).
Params(reorderParams).
Body(reorderBody).
Desc("Update key result positions (full list sent, not just changes)")
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, err := parseReorderOps(runtime.Str("ops"))
if err != nil {
return err
}
var reorderedIDs []string
var total int
if level == "objective" {
objectives, err := fetchObjectives(ctx, runtime, cycleID)
if err != nil {
return err
}
total = len(objectives)
reorderedIDs, err = buildReorderedIDs(objectives, ops, total)
if err != nil {
return err
}
// Submit reorder
params := map[string]interface{}{
"cycle_id": cycleID,
}
body := map[string]interface{}{
"objective_ids": reorderedIDs,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update objective positions")
}
} else {
// key-result level
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
if err != nil {
return err
}
total = len(keyResults)
reorderedIDs, err = buildReorderedIDs(keyResults, ops, total)
if err != nil {
return err
}
// Submit reorder
params := map[string]interface{}{
"objective_id": objectiveID,
}
body := map[string]interface{}{
"key_result_ids": reorderedIDs,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update key result positions")
}
}
// Build response
result := map[string]interface{}{
"level": level,
"cycle_id": cycleID,
"total": total,
"ordered": reorderedIDs,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully reordered %d %s(s)\n", total, level)
fmt.Fprintln(w, "New order:")
for i, id := range reorderedIDs {
fmt.Fprintf(w, " Position %d: %s\n", i+1, id)
}
})
return nil
},
}

View File

@@ -0,0 +1,712 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
// testReorderItem implements reorderItem for testing.
type testReorderItem struct {
id string
}
func (t testReorderItem) GetID() string { return t.id }
func reorderTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-reorder",
AppSecret: "secret-okr-reorder",
Brand: core.BrandFeishu,
}
}
func runReorderShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRReorder.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestReorderValidate_MissingLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestReorderValidate_InvalidLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "invalid",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
if err == nil {
t.Fatal("expected error for invalid --level")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestReorderValidate_MissingCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--ops", `[{"id":"1","position":1}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestReorderValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
if err == nil {
t.Fatal("expected error for missing --objective-id when --level=key-result")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--objective-id" {
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
}
}
func TestReorderValidate_MissingOps(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "ops") {
t.Fatalf("expected --ops required error, got: %v", err)
}
}
func TestReorderValidate_InvalidOpsJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --ops JSON")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
func TestReorderValidate_EmptyOpsArray(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", "[]",
})
if err == nil {
t.Fatal("expected error for empty --ops array")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
func TestReorderValidate_DuplicateID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1},{"id":"1","position":2}]`,
})
if err == nil {
t.Fatal("expected error for duplicate id in --ops")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate id") {
t.Fatalf("expected error to mention duplicate id, got: %v", err)
}
}
func TestReorderValidate_DuplicatePosition(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1},{"id":"2","position":1}]`,
})
if err == nil {
t.Fatal("expected error for duplicate position in --ops")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate position") {
t.Fatalf("expected error to mention duplicate position, got: %v", err)
}
}
func TestReorderValidate_NegativePosition(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":0}]`,
})
if err == nil {
t.Fatal("expected error for position <= 0")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
// --- DryRun tests ---
func TestReorderDryRun_Objectives(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
}
if !strings.Contains(output, "GET") {
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_position") {
t.Fatalf("dry-run output should contain position update API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
}
if !strings.Contains(output, "objective_ids") {
t.Fatalf("dry-run output should contain objective_ids in body, got: %s", output)
}
}
func TestReorderDryRun_KeyResults(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_position") {
t.Fatalf("dry-run output should contain key_results position update API path, got: %s", output)
}
if !strings.Contains(output, "key_result_ids") {
t.Fatalf("dry-run output should contain key_result_ids in body, got: %s", output)
}
}
// --- Execute tests ---
func TestReorderExecute_Objectives_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "3", "position": 3, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock reorder
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "3", "position": 1},
map[string]interface{}{"id": "1", "position": 2},
map[string]interface{}{"id": "2", "position": 3},
},
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"3","position":1}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "objective" {
t.Fatalf("expected level=objective, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
ordered, _ := data["ordered"].([]interface{})
if len(ordered) != 3 {
t.Fatalf("expected 3 items in ordered list, got %d", len(ordered))
}
// First should be 3, then 1, then 2
if ordered[0] != "3" || ordered[1] != "1" || ordered[2] != "2" {
t.Fatalf("expected ordered [3,1,2], got %v", ordered)
}
}
func TestReorderExecute_KeyResults_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch key results
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/456/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr1", "position": 1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr2", "position": 2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock reorder
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/objectives/456/key_results_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr2", "position": 1},
map[string]interface{}{"id": "kr1", "position": 2},
},
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--ops", `[{"id":"kr2","position":1}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "key-result" {
t.Fatalf("expected level=key-result, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
ordered, _ := data["ordered"].([]interface{})
if len(ordered) != 2 {
t.Fatalf("expected 2 items in ordered list, got %d", len(ordered))
}
if ordered[0] != "kr2" || ordered[1] != "kr1" {
t.Fatalf("expected ordered [kr2,kr1], got %v", ordered)
}
}
func TestReorderExecute_PositionOutOfRange(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives (only 2 items)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
BodyFilter: func(body []byte) bool {
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return false
}
ids, ok := data["objective_ids"].([]interface{})
if !ok || len(ids) != 2 {
return false
}
// position 5 should be clamped to position 2 (last), so order is [2, 1]
return ids[0] == "2" && ids[1] == "1"
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":5}]`, // position 5 exceeds total of 2, should clamp to last
})
if err != nil {
t.Fatalf("unexpected error for out-of-range position (should clamp): %v", err)
}
}
func TestReorderExecute_IDNotFound(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"999","position":1}]`, // ID 999 doesn't exist
})
if err == nil {
t.Fatal("expected error for non-existent ID")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected error to mention not found, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseReorderOps_Valid(t *testing.T) {
t.Parallel()
ops, err := parseReorderOps(`[{"id":"1","position":2},{"id":"2","position":1}]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ops) != 2 {
t.Fatalf("expected 2 ops, got %d", len(ops))
}
if ops[0].ID != "1" || ops[0].Position != 2 {
t.Fatalf("expected op[0] = {1,2}, got %+v", ops[0])
}
if ops[1].ID != "2" || ops[1].Position != 1 {
t.Fatalf("expected op[1] = {2,1}, got %+v", ops[1])
}
}
func TestBuildReorderedIDs(t *testing.T) {
t.Parallel()
items := []Objective{
{ID: "1"},
{ID: "2"},
{ID: "3"},
{ID: "4"},
}
ops := []reorderOp{
{ID: "4", Position: 1},
{ID: "2", Position: 3},
}
result, err := buildReorderedIDs(items, ops, 4)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 4 at pos1, 1 at pos2 (unchanged), 2 at pos3, 3 at pos4
expected := []string{"4", "1", "2", "3"}
if len(result) != len(expected) {
t.Fatalf("expected %d items, got %d", len(expected), len(result))
}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_SingleClampToEnd(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"},
}
ops := []reorderOp{
{ID: "1", Position: 99}, // position 99 exceeds total of 4, should clamp to last
}
result, err := buildReorderedIDs(items, ops, 4)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 2 at pos1, 3 at pos2, 4 at pos3, 1 at pos4 (clamped)
expected := []string{"2", "3", "4", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_MultipleClampToEnd(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
}
ops := []reorderOp{
{ID: "1", Position: 10}, // position 10 exceeds total of 5
{ID: "2", Position: 20}, // position 20 exceeds total of 5
}
result, err := buildReorderedIDs(items, ops, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 3 at pos1, 4 at pos2, 5 at pos3, 1 at pos4 (clamped, pos10 < pos20), 2 at pos5 (clamped)
expected := []string{"3", "4", "5", "1", "2"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_MixedClamp(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
}
ops := []reorderOp{
{ID: "5", Position: 1}, // normal position
{ID: "1", Position: 99}, // clamped to end
{ID: "2", Position: 50}, // clamped to end, but position 50 < 99, so comes before 1
}
result, err := buildReorderedIDs(items, ops, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 5 at pos1, 3 at pos2, 4 at pos3, 2 at pos4 (clamped pos50), 1 at pos5 (clamped pos99)
expected := []string{"5", "3", "4", "2", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_LargePositionSafe(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"},
}
// Very large position should not cause memory issues with map-based implementation
ops := []reorderOp{
{ID: "1", Position: 100000000}, // 10^8, would be dangerous with slice
}
result, err := buildReorderedIDs(items, ops, 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 2 at pos1, 3 at pos2, 1 at pos3 (clamped to end)
expected := []string{"2", "3", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}

490
shortcuts/okr/okr_weight.go Normal file
View File

@@ -0,0 +1,490 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"sort"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// weightItem is the interface for items that have ID and weight.
type weightItem interface {
GetID() string
GetWeight() float64
}
// weightOp represents a single weight assignment.
type weightOp struct {
ID string `json:"id"`
Weight float64 `json:"weight"`
}
// parseWeightOps parses and validates the --weights JSON array.
func parseWeightOps(weightsStr string) ([]weightOp, error) {
var ops []weightOp
if err := json.Unmarshal([]byte(weightsStr), &ops); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must be valid JSON array: %s", err).WithParam("--weights").WithCause(err)
}
if len(ops) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must contain at least one weight assignment").WithParam("--weights")
}
seen := make(map[string]bool)
var sum float64
for i, op := range ops {
if strings.TrimSpace(op.ID) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].id is required and cannot be empty", i).WithParam("--weights")
}
if op.Weight < 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be non-negative", i).WithParam("--weights")
}
if op.Weight > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be <= 1", i).WithParam("--weights")
}
// Check for at most 3 decimal places
if math.Round(op.Weight*1000)/1000 != op.Weight {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must have at most 3 decimal places", i).WithParam("--weights")
}
if seen[op.ID] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --weights", op.ID).WithParam("--weights")
}
seen[op.ID] = true
sum += op.Weight
}
// Sum must be <= 1
if sum > 1+1e-9 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "sum of weights must be <= 1, got %.6f", sum).WithParam("--weights")
}
return ops, nil
}
// formatWeight formats a fixed-point weight value as a json.Number with exactly 3 decimal places.
// This ensures precise JSON serialization and avoids float64 precision issues.
func formatWeight(fp int64) json.Number {
return json.Number(fmt.Sprintf("%d.%03d", fp/1000, fp%1000))
}
// normalizeWeights normalizes weights using fixed-point arithmetic (×1000).
// - Specified weights are used as-is (already validated to 3 decimal places).
// - Remaining weight (1 - sum_specified) is distributed to unspecified items
// proportionally based on their original weights.
// - Fixed-point arithmetic ensures exact sum = 1, with residual added to the last item.
// - Weights are returned as json.Number to avoid float64 precision issues in JSON serialization.
func normalizeWeights[T weightItem](
items []T,
ops []weightOp,
) ([]map[string]interface{}, error) {
const scale = 1000 // fixed-point scale for 3 decimal places
// Build map of specified weights (as fixed-point integers)
specified := make(map[string]int64)
var specifiedSum int64
for _, op := range ops {
fp := int64(math.Round(op.Weight * scale))
specified[op.ID] = fp
specifiedSum += fp
}
// Calculate remaining weight to distribute (as fixed-point)
remaining := scale - specifiedSum
if remaining < 0 {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "weight calculation error: remaining weight is negative")
}
// Collect unspecified items and their original weights
type itemWithWeight struct {
item T
fp int64 // original weight as fixed-point
}
var unspecified []itemWithWeight
var originalUnspecifiedSum int64
for _, item := range items {
id := item.GetID()
if _, ok := specified[id]; ok {
continue
}
origWeight := item.GetWeight()
if origWeight < 0 {
origWeight = 0
}
fp := int64(math.Round(origWeight * scale))
unspecified = append(unspecified, itemWithWeight{item: item, fp: fp})
originalUnspecifiedSum += fp
}
// Distribute remaining weight proportionally
result := make([]map[string]interface{}, 0, len(items))
var resultSum int64
// First add specified items in original order
for _, item := range items {
id := item.GetID()
if fp, ok := specified[id]; ok {
result = append(result, map[string]interface{}{
"id": id,
"weight": formatWeight(fp),
})
resultSum += fp
}
}
// Then distribute to unspecified items
if len(unspecified) > 0 && remaining > 0 {
if originalUnspecifiedSum == 0 {
// All original weights are zero, distribute evenly
perItem := remaining / int64(len(unspecified))
residual := remaining - perItem*int64(len(unspecified))
for i, uw := range unspecified {
fp := perItem
// Add residual to the last unspecified item
if i == len(unspecified)-1 {
fp += residual
}
result = append(result, map[string]interface{}{
"id": uw.item.GetID(),
"weight": formatWeight(fp),
})
resultSum += fp
}
} else {
// Distribute proportionally based on original weights
var distributed int64
for i, uw := range unspecified {
var fp int64
if i == len(unspecified)-1 {
// Last item gets the remainder to ensure exact sum
fp = remaining - distributed
} else {
// Proportional distribution
fp = int64(float64(remaining) * float64(uw.fp) / float64(originalUnspecifiedSum))
distributed += fp
}
result = append(result, map[string]interface{}{
"id": uw.item.GetID(),
"weight": formatWeight(fp),
})
resultSum += fp
}
}
} else if remaining > 0 {
// All items were specified, add residual to the last item
if len(result) > 0 {
lastIdx := len(result) - 1
// Parse current weight as fixed-point and add residual
var lastFP int64
if lastWeight, ok := result[lastIdx]["weight"].(json.Number); ok {
if f, err := lastWeight.Float64(); err == nil {
lastFP = int64(math.Round(f * scale))
}
}
result[lastIdx]["weight"] = formatWeight(lastFP + remaining)
resultSum += remaining
}
}
// Verify sum is exactly 1.0
if resultSum != scale {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"weight normalization error: sum is %.6f, expected 1.0", float64(resultSum)/scale)
}
return result, nil
}
// GetWeight implements the interface for Objective.
func (o Objective) GetWeight() float64 {
if o.Weight == nil {
return 0
}
return *o.Weight
}
// GetWeight implements the interface for KeyResult.
func (k KeyResult) GetWeight() float64 {
if k.Weight == nil {
return 0
}
return *k.Weight
}
// OKRWeight adjusts the weight of objectives or key results.
var OKRWeight = common.Shortcut{
Service: "okr",
Command: "+weight",
Description: "Adjust the weight of OKR objectives or key results",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to adjust: objective | key-result", Enum: []string{"objective", "key-result"}, Required: true},
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
{Name: "weights", Desc: "JSON array of weight assignments: [{\"id\":\"...\",\"weight\":0.5}]", Input: []string{common.File, common.Stdin}, Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
cycleID := runtime.Str("cycle-id")
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
if level == "key-result" {
objID := runtime.Str("objective-id")
if objID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
}
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
}
}
weightsStr := runtime.Str("weights")
if err := common.RejectDangerousCharsTyped("--weights", weightsStr); err != nil {
return err
}
if _, err := parseWeightOps(weightsStr); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, _ := parseWeightOps(runtime.Str("weights"))
apis := common.NewDryRunAPI()
if level == "objective" {
// First fetch objectives
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all objectives in the cycle to get current weights for normalization")
// Then update weights
weightParams := map[string]interface{}{
"cycle_id": cycleID,
}
// Build sample body
objectiveWeights := make([]map[string]interface{}, 0, len(ops))
for _, op := range ops {
objectiveWeights = append(objectiveWeights, map[string]interface{}{
"objective_id": op.ID,
"weight": op.Weight,
})
}
weightBody := map[string]interface{}{
"objective_weights": objectiveWeights,
}
weightPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
apis = apis.
PUT(weightPath).
Params(weightParams).
Body(weightBody).
Desc("Update objective weights (full list sent after normalization)")
} else {
// key-result level
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all key results for the objective to get current weights for normalization")
weightParams := map[string]interface{}{
"objective_id": objectiveID,
}
// Build sample body
keyResultWeights := make([]map[string]interface{}, 0, len(ops))
for _, op := range ops {
keyResultWeights = append(keyResultWeights, map[string]interface{}{
"key_result_id": op.ID,
"weight": op.Weight,
})
}
weightBody := map[string]interface{}{
"key_result_weights": keyResultWeights,
}
weightPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
apis = apis.
PUT(weightPath).
Params(weightParams).
Body(weightBody).
Desc("Update key result weights (full list sent after normalization)")
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, err := parseWeightOps(runtime.Str("weights"))
if err != nil {
return err
}
var normalizedWeights []map[string]interface{}
var total int
if level == "objective" {
objectives, err := fetchObjectives(ctx, runtime, cycleID)
if err != nil {
return err
}
total = len(objectives)
// Validate all specified IDs exist
objIDs := make(map[string]bool)
for _, obj := range objectives {
objIDs[obj.ID] = true
}
for _, op := range ops {
if !objIDs[op.ID] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "objective id %q not found in cycle", op.ID).WithParam("--weights")
}
}
normalizedWeights, err = normalizeWeights(objectives, ops)
if err != nil {
return err
}
// Build position map for sorting
posMap := make(map[string]int32)
for _, obj := range objectives {
if obj.Position != nil {
posMap[obj.ID] = *obj.Position
}
}
// Submit weight update
params := map[string]interface{}{
"cycle_id": cycleID,
}
objectiveWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
for _, w := range normalizedWeights {
objectiveWeights = append(objectiveWeights, map[string]interface{}{
"objective_id": w["id"],
"weight": w["weight"],
})
}
// Sort by position to match API requirements
sort.Slice(objectiveWeights, func(i, j int) bool {
idI := objectiveWeights[i]["objective_id"].(string)
idJ := objectiveWeights[j]["objective_id"].(string)
return posMap[idI] < posMap[idJ]
})
body := map[string]interface{}{
"objective_weights": objectiveWeights,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update objective weights")
}
} else {
// key-result level
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
if err != nil {
return err
}
total = len(keyResults)
// Validate all specified IDs exist
krIDs := make(map[string]bool)
for _, kr := range keyResults {
krIDs[kr.ID] = true
}
for _, op := range ops {
if !krIDs[op.ID] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "key_result id %q not found in objective", op.ID).WithParam("--weights")
}
}
normalizedWeights, err = normalizeWeights(keyResults, ops)
if err != nil {
return err
}
// Build position map for sorting
posMap := make(map[string]int32)
for _, kr := range keyResults {
if kr.Position != nil {
posMap[kr.ID] = *kr.Position
}
}
// Submit weight update
params := map[string]interface{}{
"objective_id": objectiveID,
}
keyResultWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
for _, w := range normalizedWeights {
keyResultWeights = append(keyResultWeights, map[string]interface{}{
"key_result_id": w["id"],
"weight": w["weight"],
})
}
// Sort by position to match API requirements
sort.Slice(keyResultWeights, func(i, j int) bool {
idI := keyResultWeights[i]["key_result_id"].(string)
idJ := keyResultWeights[j]["key_result_id"].(string)
return posMap[idI] < posMap[idJ]
})
body := map[string]interface{}{
"key_result_weights": keyResultWeights,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update key result weights")
}
}
// Build response
result := map[string]interface{}{
"level": level,
"cycle_id": cycleID,
"total": total,
"weights": normalizedWeights,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully updated weights for %d %s(s)\n", total, level)
fmt.Fprintln(w, "Weights:")
for _, weightEntry := range normalizedWeights {
fmt.Fprintf(w, " %s: %v\n", weightEntry["id"], weightEntry["weight"])
}
})
return nil
},
}

View File

@@ -0,0 +1,747 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"math"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
// getWeightFloat extracts a float64 weight from either float64 or json.Number.
func getWeightFloat(v interface{}) float64 {
switch val := v.(type) {
case float64:
return val
case json.Number:
f, _ := val.Float64()
return f
default:
return 0
}
}
func weightTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
return &core.CliConfig{
AppID: "test-okr-weight",
AppSecret: "secret-okr-weight",
Brand: core.BrandFeishu,
}
}
func runWeightShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRWeight.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestWeightValidate_MissingLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestWeightValidate_InvalidLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "invalid",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for invalid --level")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestWeightValidate_MissingCycleID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--weights", `[{"id":"1","weight":0.5}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestWeightValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for missing --objective-id when --level=key-result")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--objective-id" {
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
}
}
func TestWeightValidate_MissingWeights(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "weights") {
t.Fatalf("expected --weights required error, got: %v", err)
}
}
func TestWeightValidate_InvalidWeightsJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --weights JSON")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_EmptyWeightsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", "[]",
})
if err == nil {
t.Fatal("expected error for empty --weights array")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_NegativeWeight(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":-0.1}]`,
})
if err == nil {
t.Fatal("expected error for negative weight")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "non-negative") {
t.Fatalf("expected error to mention non-negative, got: %v", err)
}
}
func TestWeightValidate_WeightGreaterThanOne(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":1.5}]`,
})
if err == nil {
t.Fatal("expected error for weight > 1")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_TooManyDecimalPlaces(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.1234}]`,
})
if err == nil {
t.Fatal("expected error for weight with more than 3 decimal places")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "3 decimal places") {
t.Fatalf("expected error to mention 3 decimal places, got: %v", err)
}
}
func TestWeightValidate_SumGreaterThanOne(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.6},{"id":"2","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for sum of weights > 1")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "sum of weights") {
t.Fatalf("expected error to mention sum of weights, got: %v", err)
}
}
func TestWeightValidate_DuplicateID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.3},{"id":"1","weight":0.4}]`,
})
if err == nil {
t.Fatal("expected error for duplicate id in --weights")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate id") {
t.Fatalf("expected error to mention duplicate id, got: %v", err)
}
}
// --- DryRun tests ---
func TestWeightDryRun_Objectives(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5},{"id":"2","weight":0.5}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
}
if !strings.Contains(output, "GET") {
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_weight") {
t.Fatalf("dry-run output should contain weight update API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
}
if !strings.Contains(output, "objective_weights") {
t.Fatalf("dry-run output should contain objective_weights in body, got: %s", output)
}
}
func TestWeightDryRun_KeyResults(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--weights", `[{"id":"kr1","weight":0.5},{"id":"kr2","weight":0.5}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_weight") {
t.Fatalf("dry-run output should contain key_results weight update API path, got: %s", output)
}
if !strings.Contains(output, "key_result_weights") {
t.Fatalf("dry-run output should contain key_result_weights in body, got: %s", output)
}
}
// --- Execute tests ---
func TestWeightExecute_Objectives_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch objectives
w1 := 0.5
w2 := 0.5
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "weight": &w2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock weight update
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_weight",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "weight": 0.7},
map[string]interface{}{"id": "2", "weight": 0.3},
},
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.7}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "objective" {
t.Fatalf("expected level=objective, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
weights, _ := data["weights"].([]interface{})
if len(weights) != 2 {
t.Fatalf("expected 2 items in weights list, got %d", len(weights))
}
// Verify sum is exactly 1.0
var sum float64
for _, w := range weights {
wm, _ := w.(map[string]interface{})
weightVal := getWeightFloat(wm["weight"])
sum += weightVal
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
}
}
func TestWeightExecute_KeyResults_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch key results
w1 := 0.3
w2 := 0.3
w3 := 0.4
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/456/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr1", "weight": &w1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr2", "weight": &w2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr3", "weight": &w3, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock weight update
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/objectives/456/key_results_weight",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--weights", `[{"id":"kr1","weight":0.5}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "key-result" {
t.Fatalf("expected level=key-result, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
weights, _ := data["weights"].([]interface{})
// Verify sum is exactly 1.0
var sum float64
for _, w := range weights {
wm, _ := w.(map[string]interface{})
weightVal := getWeightFloat(wm["weight"])
sum += weightVal
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
}
}
func TestWeightExecute_IDNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch objectives
w1 := 0.5
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"999","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for non-existent ID")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected error to mention not found, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseWeightOps_Valid(t *testing.T) {
ops, err := parseWeightOps(`[{"id":"1","weight":0.3},{"id":"2","weight":0.7}]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ops) != 2 {
t.Fatalf("expected 2 ops, got %d", len(ops))
}
if ops[0].ID != "1" || math.Abs(ops[0].Weight-0.3) > 1e-9 {
t.Fatalf("expected op[0] = {1,0.3}, got %+v", ops[0])
}
if ops[1].ID != "2" || math.Abs(ops[1].Weight-0.7) > 1e-9 {
t.Fatalf("expected op[1] = {2,0.7}, got %+v", ops[1])
}
}
func TestNormalizeWeights_AllSpecified(t *testing.T) {
w1 := 0.0
w2 := 0.0
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
}
ops := []weightOp{
{ID: "1", Weight: 0.3},
{ID: "2", Weight: 0.7},
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
}
func TestNormalizeWeights_PartialSpecified_Proportional(t *testing.T) {
w1 := 0.5
w2 := 0.3
w3 := 0.2
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
{ID: "3", Weight: &w3},
}
ops := []weightOp{
{ID: "1", Weight: 0.4}, // Specify 0.4 for item 1
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 3 {
t.Fatalf("expected 3 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
// Item 1 should have weight 0.4
var item1Weight float64
for _, r := range result {
if r["id"] == "1" {
item1Weight = getWeightFloat(r["weight"])
break
}
}
if math.Abs(item1Weight-0.4) > 1e-9 {
t.Fatalf("expected item 1 weight = 0.4, got %.10f", item1Weight)
}
}
func TestNormalizeWeights_ZeroOriginalWeights(t *testing.T) {
w1 := 0.0
w2 := 0.0
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
}
ops := []weightOp{
{ID: "1", Weight: 0.5},
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
// When original weights are zero, remaining should be distributed evenly
var item2Weight float64
for _, r := range result {
if r["id"] == "2" {
item2Weight = getWeightFloat(r["weight"])
break
}
}
if math.Abs(item2Weight-0.5) > 1e-9 {
t.Fatalf("expected item 2 weight = 0.5 (even distribution), got %.10f", item2Weight)
}
}

View File

@@ -18,5 +18,9 @@ func Shortcuts() []common.Shortcut {
OKRUpdateProgressRecord,
OKRDeleteProgressRecord,
OKRUploadImage,
OKRBatchCreate,
OKRReorder,
OKRWeight,
OKRIndicatorUpdate,
}
}

View File

@@ -53,7 +53,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
## Resource Rendering
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `[Image: img_xxx]`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `![Image](img_xxx)`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
Two ways to get the binaries:
- **In one pass:** add `--download-resources` to this command — every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) is downloaded into `./lark-im-resources/` and a `resources` block (`{message_id, key, type, local_path, size_bytes}`) is attached to each message. See [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in).
@@ -61,7 +61,7 @@ Two ways to get the binaries:
| Resource Type | Marker in Content | Behavior |
|---------|-------------|------|
| Image | `[Image: img_xxx]` | `--download-resources`, or manually `im +messages-resources-download --type image` |
| Image | `![Image](img_xxx)` | `--download-resources`, or manually `im +messages-resources-download --type image` |
| File | `<file key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Audio | `<audio key="file_xxx" duration="Xs"/>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Video | `<video key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |

View File

@@ -32,7 +32,7 @@ When enabled:
- Output paths are confined to `./lark-im-resources/` by the same guards as [`+messages-resources-download`](lark-im-messages-resources-download.md) (abnormal `file_key` with path separators / `..` / absolute paths is rejected).
- **Scope**: the download uses `GET /open-apis/im/v1/messages/:message_id/resources/:file_key`, which requires `im:message:readonly` — already declared in each listing command's `Scopes`, so `--download-resources` needs **no extra scope** beyond what's required to read the messages (user identity also needs `im:message.group_msg:get_as_user` / `im:message.p2p_msg:get_as_user`; bot identity needs `im:message.group_msg` / `im:message.p2p_msg:readonly`, all already declared). Works under both user and bot identity. If a bot was registered before `im:message:readonly` was granted, a single resource will fail-silently (`error: true` + stderr warning) rather than aborting the pull.
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. `[Image: img_xxx]`, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. `![Image](img_xxx)`, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
## Scope requirement

View File

@@ -90,7 +90,7 @@ lark-cli im +messages-mget --message-ids "om_aaa,om_bbb"
1. **Use JSON for full content:** table output truncates content. Use `--format json` when the full body matters.
2. **Sender names are already enriched:** the command resolves sender names automatically, so no extra lookup is required.
3. **Images are rendered as placeholders:** image messages appear as placeholders such as `[Image: img_xxx]`. Use `+messages-resources-download` when you need the binary resource.
3. **Images are rendered as placeholders:** image messages appear as placeholders such as `![Image](img_xxx)`. Use `+messages-resources-download` when you need the binary resource.
4. **Batching is more efficient:** fetching multiple IDs in one request is better than calling the API repeatedly.
## References

View File

@@ -152,7 +152,7 @@ lark-cli im +threads-messages-list --thread <thread_id>
## Resource Rendering
Search results reuse the same content formatter as other read commands. Image messages are rendered as placeholders such as `[Image: img_xxx]`; resource binaries are **not** downloaded automatically.
Search results reuse the same content formatter as other read commands. Image messages are rendered as placeholders such as `![Image](img_xxx)`; resource binaries are **not** downloaded automatically.
Use `im +messages-resources-download` if you need to fetch the underlying image or file bytes from a specific message.

View File

@@ -96,7 +96,7 @@ lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
## Resource Rendering
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `[Image: img_xxx]`; by default resource binaries are **not** downloaded.
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `![Image](img_xxx)`; by default resource binaries are **not** downloaded.
Pass `--download-resources` to download every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` in one pass and attach a `resources` block to each reply (see [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in)). Otherwise download individual resources manually through `im +messages-resources-download` (see [lark-im-messages-resources-download](lark-im-messages-resources-download.md)).

View File

@@ -18,16 +18,20 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|--------------------------------------------------------------|--------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
| Shortcut | 说明 |
|----------------------------------------------------------------|--------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
| [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective 和 KR |
| [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 |
| [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 |
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值 |
## 格式说明

View File

@@ -0,0 +1,106 @@
# okr +batch-create
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
批量创建 OKR 目标Objective和关键结果Key Result
## 推荐命令
```bash
# 批量创建 2 个 Objective各带 2 个 KR。
lark-cli okr +batch-create \
--cycle-id 7000000000000000001 \
--input '[
{
"text": "提升产品用户体验",
"mention": ["ou_xxxxxxxx"],
"krs": [
{"text": "页面加载速度提升 50%", "mention": ["ou_yyyyyyyy"]},
{"text": "用户满意度达到 4.8 分"}
]
},
{
"text": "拓展新市场份额",
"krs": [
{"text": "新增 10 个城市覆盖"},
{"text": "市场份额提升至 25%"}
]
}
]' \
--as user
# 从文件读取输入
lark-cli okr +batch-create \
--cycle-id 7000000000000000001 \
--input @okr_batch.json \
--as user
# 预览 API 调用Dry-run
lark-cli okr +batch-create \
--cycle-id 7000000000000000001 \
--input @okr_batch.json \
--dry-run \
--as user
```
- mention 是可选参数,不需要使用“@”提及其他用户时不传入。
- 传入的 mention 参数会以 @对应用户的形式,添加在文本后。
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------------|----|-----------|------------------------------------------------------------|
| `--cycle-id` | 是 | — | OKR 周期 IDint64 类型) |
| `--input` | 是 | — | JSON 数组格式的 Objective 列表。支持 `@文件路径` 从文件读取或 `@-` 从 stdin 读取。 |
| `--user-id-type` | 否 | `open_id` | mention 中使用的用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 输入格式
```json
[
{
"text": "Objective 内容",
"mention": ["ou_xxxxxxxx", "ou_yyyyyyyy"],
"krs": [
{
"text": "KR 内容",
"mention": ["ou_zzzzzzzz"]
}
]
}
]
```
## 工作流程
1. 使用 `+cycle-list` 获取可用的 OKR 周期 ID
2. 构造 `--input` JSON 数组,包含要创建的 Objective 和 KR
3. 执行 `lark-cli okr +batch-create --cycle-id <id> --input '...'`
## 输出
成功返回 JSON
```json
{
"ok": true,
"data": {
"created": [
{
"objective_id": "7000000000000000002",
"krs": ["7000000000000000003", "7000000000000000004"]
},
{
"objective_id": "7000000000000000005",
"krs": ["7000000000000000006"]
}
]
}
}
```
## 参考
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,80 @@
# okr +indicator-update
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
直接更新目标Objective或关键结果Key Result的指标当前值无需手动查询指标 ID。
> **查询指标:** 如需查看指标详情,请使用原生 API
> - 目标指标:`lark-cli okr objective.indicators list --objective-id <id>`
> - KR 指标:`lark-cli okr key_result.indicators list --key-result-id <id>`
## 推荐命令
```bash
# 更新 Objective 的指标值
lark-cli okr +indicator-update \
--level objective \
--id 7000000000000000001 \
--value 75.5 \
--as user
# 更新 Key Result 的指标值
lark-cli okr +indicator-update \
--level key-result \
--id 7000000000000000002 \
--value 100 \
--as user
```
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------|----|--------|--------------------------------------------------------------------|
| `--level` | 是 | — | 操作层级:`objective`(更新目标指标)\| `key-result`(更新 KR 指标) |
| `--id` | 是 | — | 目标 ID 或 KR IDint64 类型) |
| `--value` | 是 | — | 新的指标当前值(数字,范围:-99999999999 到 99999999999 |
| `--dry-run`| 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 工作流程
1. 使用 `+cycle-list``+cycle-detail` 获取目标 ID 或 KR ID。
2. 如需查看当前指标值,使用 `objective.indicators list``key_result.indicators list` 查询。
3. 执行 `+indicator-update` 指定层级、ID 和新值。
4. 命令自动查询指标 ID 并更新当前值。
## 输出
### JSON 格式
```json
{
"ok": true,
"data": {
"indicator_id": "7000000000000000003",
"current_value": 75.5,
"level": "objective",
"target_id": "7000000000000000001"
}
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|----------------|--------|------------------------|
| `indicator_id` | string | 被更新的指标 ID |
| `current_value`| number | 更新后的指标当前值 |
| `level` | string | 操作层级:`objective` / `key-result` |
| `target_id` | string | 目标或 KR 的 ID |
## 注意事项
- 仅更新 `current_value` 字段,`unit``start_value``target_value` 等其他字段保持不变
- 若需要这些字段进行修改,使用原生接口 indicators.patch
- 指标的 `current_value_calculate_type` 必须为「手动更新」才能通过此命令修改。
## 参考
- [OKR 指标更新 API](https://open.feishu.cn/api-explorer?from=op_doc_tab&apiName=patch&project=okr&resource=okr.indicator&version=v2)
- [`lark-okr-progress-create.md`](./lark-okr-progress-create.md) — 创建进度记录
- [`lark-okr-cycle-detail.md`](./lark-okr-cycle-detail.md) — 查询周期详情获取 ID

View File

@@ -0,0 +1,81 @@
# okr +reorder
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
调整 OKR 周期下目标Objective或目标下关键结果Key Result的顺序。
## 推荐命令
```bash
# 调整 Objective 顺位
lark-cli okr +reorder \
--cycle-id 7000000000000000001 \
--level objective \
--ops '[
{"id": "7000000000000000002", "position": 2},
{"id": "7000000000000000003", "position": 1}
]' \
--as user
# 调整 KR 顺位(需指定 --objective-id
lark-cli okr +reorder \
--cycle-id 7000000000000000001 \
--level key-result \
--objective-id 7000000000000000002 \
--ops '[
{"id": "7000000000000000004", "position": 1},
{"id": "7000000000000000005", "position": 2}
]' \
--as user
# 从文件读取 ops
lark-cli okr +reorder \
--cycle-id 7000000000000000001 \
--level objective \
--ops @reorder_ops.json \
--as user
```
- 不允许将多个 objective/key-result 放在同一个位置下
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------------|----|--------|---------------------------------------------------------|
| `--level` | 是 | — | 调整层级:`objective`(调整周期下目标顺序)\| `key-result`(调整目标下 KR 顺序) |
| `--cycle-id` | 是 | — | OKR 周期 IDint64 类型)。 |
| `--objective-id` | 条件 | — | 目标 ID。当 `--level=key-result` 时**必填**,用于定位父目标。 |
| `--ops` | 是 | — | JSON 数组格式的顺位调整操作。支持 `@文件路径``@-` 从 stdin 读取。 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 工作流程
1. 使用 `+cycle-list``+cycle-detail` 获取周期 ID、目标 ID 和 KR ID。
2. 构造 `--ops` JSON 数组,指定要调整的 ID 和新 position执行命令。
3. 返回调整后的完整顺序。
## 输出
成功返回 JSON以调整 Objective 位置为例):
```json
{
"ok": true,
"data": {
"level": "objective",
"cycle_id": "7000000000000000001",
"total": 3,
"ordered": [
"7000000000000000003",
"7000000000000000002",
"7000000000000000004"
]
}
}
```
## 参考
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,96 @@
# okr +weight
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
调整 OKR 周期下目标Objective或目标下关键结果Key Result的权重。支持部分指定权重未指定的按原权重比例自动分配。
## 推荐命令
```bash
# 调整 Objective 权重(部分指定,剩余自动分配)
lark-cli okr +weight \
--cycle-id 7000000000000000001 \
--level objective \
--weights '[
{"id": "7000000000000000002", "weight": 0.6},
{"id": "7000000000000000003", "weight": 0.3}
]' \
--as user
# 调整 KR 权重(全部指定,和为 1
lark-cli okr +weight \
--cycle-id 7000000000000000001 \
--level key-result \
--objective-id 7000000000000000002 \
--weights '[
{"id": "7000000000000000004", "weight": 0.6},
{"id": "7000000000000000005", "weight": 0.4}
]' \
--as user
# 从文件读取 weights
lark-cli okr +weight \
--cycle-id 7000000000000000001 \
--level objective \
--weights @weights.json \
--as user
```
参数限制: 请求中的权重保留三位小数,分配的所有权重和不能大于 1 (小于等于 1 是允许的)。
### 权重归一化
- 在 OKR 中,一个周期下所有 Objective 和 一个 Objective 下所有 Key Result 的权重和固定为 1.
- 在使用 +weight shortcut 分配 OKR 权重时,已分配的总权重不得超过 1。
- 若已分配的权重 < 1剩余的权重会按照原始权重的比例均分到未指定的 Objective/Key Result 下。
- 若所有 Objective/Key Result 均分配了权重但和 < 1剩余的权重会计算在最后一个 Objective/Key Result 下。
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------------|----|--------|---------------------------------------------------------------------|
| `--level` | 是 | — | 调整层级:`objective`(调整周期下目标权重)\| `key-result`(调整目标下 KR 权重) |
| `--cycle-id` | 是 | — | OKR 周期 IDint64 类型) |
| `--objective-id` | 条件 | — | 目标 ID。当 `--level=key-result` 时**必填**,用于定位父目标。 |
| `--weights` | 是 | — | JSON 数组格式的权重分配。支持 `@文件路径``@-` 从 stdin 读取。权重保留三位小数,分配的所有权重和不能大于 1 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 工作流程
1. 使用 `+cycle-list``+cycle-detail` 获取周期 ID、目标 ID、KR ID 和当前权重。
2. 构造 `--weights` JSON 数组,指定要调整的 ID 和权重,执行命令。
3. 返回调整后的完整权重列表。
## 输出
成功返回 JSON
```json
{
"ok": true,
"data": {
"level": "objective",
"cycle_id": "7000000000000000001",
"total": 3,
"weights": [
{"id": "7000000000000000002", "weight": 0.6},
{"id": "7000000000000000003", "weight": 0.3},
{"id": "7000000000000000004", "weight": 0.1}
]
}
}
```
## 关于 1001001 错误
有时,即使输入的参数完全正确, +weight 也会返回 1001001 错误。这是因为你的租户设置中,不一定开启了目标或关键结果的设置权重功能。
若你确认输入的参数无误cycle-id/objective-id 正确weights 中的 id 均是同一个周期下的目标或同一个目标下的关键结果weights 中的权重和 <1,
不必进一步尝试,你需要向用户确认 OKR 应用目前是否开启了目标或关键结果的设置权重功能。
## 参考
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,456 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// --- Dry-run E2E tests for +batch-create, +reorder, +weight ---
// TestOKR_BatchCreateDryRun validates +batch-create dry-run output contains expected API paths.
func TestOKR_BatchCreateDryRun(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+batch-create",
"--cycle-id", "123456",
"--input", `[{"text":"Objective 1","krs":[{"text":"KR 1"}]}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain objective API path, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/"), "dry-run should contain KR API path prefix, got: %s", output)
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
}
// TestOKR_BatchCreateDryRun_WithUserIDType validates +batch-create dry-run with --user-id-type.
func TestOKR_BatchCreateDryRun_WithUserIDType(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+batch-create",
"--cycle-id", "123456",
"--input", `[{"text":"Objective 1","krs":[{"text":"KR 1"}]}]`,
"--user-id-type", "user_id",
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "user_id"), "dry-run should contain user-id-type, got: %s", output)
}
// TestOKR_ReorderDryRun validates +reorder dry-run output contains expected API paths.
func TestOKR_ReorderDryRun(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+reorder",
"--cycle-id", "123456",
"--level", "objective",
"--ops", `[{"id":"obj_1","position":2},{"id":"obj_2","position":1}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain objective API path, got: %s", output)
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
}
// TestOKR_ReorderDryRun_KR validates +reorder dry-run with --level=key-result.
func TestOKR_ReorderDryRun_KR(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+reorder",
"--cycle-id", "123456",
"--objective-id", "789",
"--level", "key-result",
"--ops", `[{"id":"1001","position":2},{"id":"1002","position":1}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/789/key_results"), "dry-run should contain KR API path, got: %s", output)
assert.True(t, strings.Contains(output, "789"), "dry-run should contain objective-id, got: %s", output)
}
// TestOKR_WeightDryRun validates +weight dry-run output contains expected API paths.
func TestOKR_WeightDryRun(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+weight",
"--cycle-id", "123456",
"--level", "objective",
"--weights", `[{"id":"obj_1","weight":0.6},{"id":"obj_2","weight":0.4}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain objective API path, got: %s", output)
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
}
// TestOKR_WeightDryRun_KR validates +weight dry-run with --level=key-result.
func TestOKR_WeightDryRun_KR(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+weight",
"--cycle-id", "123456",
"--objective-id", "789",
"--level", "key-result",
"--weights", `[{"id":"1001","weight":0.5},{"id":"1002","weight":0.5}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/789/key_results"), "dry-run should contain KR API path, got: %s", output)
assert.True(t, strings.Contains(output, "789"), "dry-run should contain objective-id, got: %s", output)
}
// --- Live E2E tests (require user token, skip otherwise) ---
// getTestCycleID returns the test cycle ID from env var, or skips the test.
func getTestCycleID(t *testing.T) string {
t.Helper()
cycleID := os.Getenv("OKR_TEST_CYCLE_ID")
if cycleID == "" {
t.Skip("OKR_TEST_CYCLE_ID not set; set to a valid cycle ID for live E2E tests")
}
return cycleID
}
// liveTestCreated tracks resources created during a live test for cleanup.
type liveTestCreated struct {
ObjectiveID string
KRIDs []string
}
// createTestObjectives creates test objectives using +batch-create and returns the created IDs.
func createTestObjectives(t *testing.T, ctx context.Context, cycleID string, suffix string) []liveTestCreated {
t.Helper()
input := []map[string]interface{}{
{
"text": fmt.Sprintf("E2E Test Objective A %s", suffix),
"krs": []map[string]interface{}{
{"text": fmt.Sprintf("E2E Test KR A1 %s", suffix)},
{"text": fmt.Sprintf("E2E Test KR A2 %s", suffix)},
},
},
{
"text": fmt.Sprintf("E2E Test Objective B %s", suffix),
"krs": []map[string]interface{}{
{"text": fmt.Sprintf("E2E Test KR B1 %s", suffix)},
},
},
}
inputJSON, _ := json.Marshal(input)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+batch-create",
"--cycle-id", cycleID,
"--input", string(inputJSON),
},
})
require.NoError(t, err, "failed to create test objectives")
result.AssertExitCode(t, 0)
var created []liveTestCreated
createdArr := gjson.Get(result.Stdout, "data.created").Array()
for _, obj := range createdArr {
objectiveID := obj.Get("objective_id").String()
var krIDs []string
for _, kr := range obj.Get("krs").Array() {
krIDs = append(krIDs, kr.String())
}
created = append(created, liveTestCreated{
ObjectiveID: objectiveID,
KRIDs: krIDs,
})
}
require.Len(t, created, 2, "expected 2 objectives created")
require.Len(t, created[0].KRIDs, 2, "expected 2 KRs for first objective")
require.Len(t, created[1].KRIDs, 1, "expected 1 KR for second objective")
require.NotEmpty(t, created[0].ObjectiveID, "objective_id should not be empty")
require.NotEmpty(t, created[0].KRIDs[0], "kr_id should not be empty")
return created
}
// cleanupLiveTest deletes KRs first, then objectives, using the raw API service commands.
func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
t.Helper()
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
// Delete in reverse order: KRs first, then objectives
for i := len(created) - 1; i >= 0; i-- {
obj := created[i]
// Delete KRs first (reverse order)
for j := len(obj.KRIDs) - 1; j >= 0; j-- {
krID := obj.KRIDs[j]
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"okr", "v2/key_results", "delete",
"--key-result-id", krID,
"--yes",
},
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete KR %s", krID), result, err)
select {
case <-cleanupCtx.Done():
return
case <-time.After(200 * time.Millisecond):
}
}
// Then delete the objective
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"okr", "v2/objectives", "delete",
"--objective-id", obj.ObjectiveID,
"--yes",
},
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete objective %s", obj.ObjectiveID), result, err)
if i > 0 {
select {
case <-cleanupCtx.Done():
return
case <-time.After(200 * time.Millisecond):
}
}
}
}
// TestOKR_BatchCreateLive validates +batch-create with real API calls: create, verify, cleanup.
func TestOKR_BatchCreateLive(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
cycleID := getTestCycleID(t)
suffix := clie2e.GenerateSuffix()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
// Create test objectives
created := createTestObjectives(t, ctx, cycleID, suffix)
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
t.Cleanup(func() {
cleanupLiveTest(t, created)
})
// Verify: call +cycle-detail to confirm objectives exist
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
foundCount := 0
for _, obj := range objectives {
objID := obj.Get("id").String()
for _, c := range created {
if objID == c.ObjectiveID {
foundCount++
// Verify KRs exist under this objective
krs := obj.Get("key_results").Array()
krIDs := make(map[string]bool)
for _, kr := range krs {
krIDs[kr.Get("id").String()] = true
}
for _, expectedKR := range c.KRIDs {
assert.True(t, krIDs[expectedKR], "expected KR %s to exist under objective %s", expectedKR, objID)
}
}
}
}
assert.Equal(t, len(created), foundCount, "all created objectives should be found in cycle detail")
}
// TestOKR_ReorderLive validates +reorder with real API calls: create, reorder, verify, cleanup.
func TestOKR_ReorderLive(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
cycleID := getTestCycleID(t)
suffix := clie2e.GenerateSuffix()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
// Create test objectives (A, then B)
created := createTestObjectives(t, ctx, cycleID, suffix)
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
t.Cleanup(func() {
cleanupLiveTest(t, created)
})
objA := created[0].ObjectiveID
objB := created[1].ObjectiveID
// Reorder: swap positions (B at position 1, A at position 2)
ops := []map[string]interface{}{
{"id": objB, "position": 1},
{"id": objA, "position": 2},
}
opsJSON, _ := json.Marshal(ops)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+reorder",
"--cycle-id", cycleID,
"--level", "objective",
"--ops", string(opsJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// Verify order via +cycle-detail
result, err = clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
var foundIDs []string
for _, obj := range objectives {
objID := obj.Get("id").String()
if objID == objA || objID == objB {
foundIDs = append(foundIDs, objID)
}
}
require.Len(t, foundIDs, 2, "should find both test objectives")
assert.Equal(t, objB, foundIDs[0], "after reorder, objective B should be first")
assert.Equal(t, objA, foundIDs[1], "after reorder, objective A should be second")
}
// TestOKR_WeightLive validates +weight with real API calls: create, set weights, verify sum=1.0, cleanup.
func TestOKR_WeightLive(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
cycleID := getTestCycleID(t)
suffix := clie2e.GenerateSuffix()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
// Create test objectives
created := createTestObjectives(t, ctx, cycleID, suffix)
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
t.Cleanup(func() {
cleanupLiveTest(t, created)
})
objA := created[0].ObjectiveID
objB := created[1].ObjectiveID
// Set weights: A=0.6, B=0.4 (sum=1.0)
weights := []map[string]interface{}{
{"id": objA, "weight": 0.6},
{"id": objB, "weight": 0.4},
}
weightsJSON, _ := json.Marshal(weights)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+weight",
"--cycle-id", cycleID,
"--level", "objective",
"--weights", string(weightsJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// Verify weights via +cycle-detail
result, err = clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
var weightA, weightB float64
for _, obj := range objectives {
objID := obj.Get("id").String()
if objID == objA {
weightA = obj.Get("weight").Float()
} else if objID == objB {
weightB = obj.Get("weight").Float()
}
}
// Verify weights are set correctly (allowing for floating point tolerance)
assert.InDelta(t, 0.6, weightA, 0.001, "objective A weight should be 0.6")
assert.InDelta(t, 0.4, weightB, 0.001, "objective B weight should be 0.4")
// Verify sum = 1.0
sumWeights := weightA + weightB
assert.InDelta(t, 1.0, sumWeights, 0.001, "sum of weights should be 1.0, got %.6f", sumWeights)
}