mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Compare commits
5 Commits
feat/confi
...
v1.0.56
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba13cfe0f | ||
|
|
815cdb8f1c | ||
|
|
4f3ae0c71a | ||
|
|
96d70143c5 | ||
|
|
83db15907f |
24
CHANGELOG.md
24
CHANGELOG.md
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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[:])
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
1093
shortcuts/im/convert_lib/card_userdsl.go
Normal file
1093
shortcuts/im/convert_lib/card_userdsl.go
Normal file
File diff suppressed because it is too large
Load Diff
993
shortcuts/im/convert_lib/card_userdsl_test.go
Normal file
993
shortcuts/im/convert_lib/card_userdsl_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
if got != want {
|
||||
t.Fatalf("extractPostBlocksText() = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
@@ -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("", key)
|
||||
}
|
||||
return "[Image]"
|
||||
case "media":
|
||||
|
||||
@@ -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: ""},
|
||||
{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> `
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
385
shortcuts/okr/okr_batch_create.go
Normal file
385
shortcuts/okr/okr_batch_create.go
Normal 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)
|
||||
}
|
||||
593
shortcuts/okr/okr_batch_create_test.go
Normal file
593
shortcuts/okr/okr_batch_create_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
178
shortcuts/okr/okr_indicator_update.go
Normal file
178
shortcuts/okr/okr_indicator_update.go
Normal 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
|
||||
},
|
||||
}
|
||||
391
shortcuts/okr/okr_indicator_update_test.go
Normal file
391
shortcuts/okr/okr_indicator_update_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
448
shortcuts/okr/okr_reorder.go
Normal file
448
shortcuts/okr/okr_reorder.go
Normal 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
|
||||
},
|
||||
}
|
||||
712
shortcuts/okr/okr_reorder_test.go
Normal file
712
shortcuts/okr/okr_reorder_test.go
Normal 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
490
shortcuts/okr/okr_weight.go
Normal 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
|
||||
},
|
||||
}
|
||||
747
shortcuts/okr/okr_weight_test.go
Normal file
747
shortcuts/okr/okr_weight_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,9 @@ func Shortcuts() []common.Shortcut {
|
||||
OKRUpdateProgressRecord,
|
||||
OKRDeleteProgressRecord,
|
||||
OKRUploadImage,
|
||||
OKRBatchCreate,
|
||||
OKRReorder,
|
||||
OKRWeight,
|
||||
OKRIndicatorUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ``; 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 | `` | `--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` |
|
||||
|
||||
@@ -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. ``, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
|
||||
|
||||
## Scope requirement
|
||||
|
||||
|
||||
@@ -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 ``. 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
|
||||
|
||||
@@ -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 ``; 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.
|
||||
|
||||
|
||||
@@ -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 ``; 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)).
|
||||
|
||||
|
||||
@@ -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 的指标当前值 |
|
||||
|
||||
## 格式说明
|
||||
|
||||
|
||||
106
skills/lark-okr/references/lark-okr-batch-create.md
Normal file
106
skills/lark-okr/references/lark-okr-batch-create.md
Normal 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 周期 ID(int64 类型) |
|
||||
| `--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) -- 认证和全局参数
|
||||
80
skills/lark-okr/references/lark-okr-indicator-update.md
Normal file
80
skills/lark-okr/references/lark-okr-indicator-update.md
Normal 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 ID(int64 类型) |
|
||||
| `--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
|
||||
81
skills/lark-okr/references/lark-okr-reorder.md
Normal file
81
skills/lark-okr/references/lark-okr-reorder.md
Normal 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 周期 ID(int64 类型)。 |
|
||||
| `--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) -- 认证和全局参数
|
||||
96
skills/lark-okr/references/lark-okr-weight.md
Normal file
96
skills/lark-okr/references/lark-okr-weight.md
Normal 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 周期 ID(int64 类型) |
|
||||
| `--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) -- 认证和全局参数
|
||||
456
tests/cli_e2e/okr/okr_shortcuts_test.go
Normal file
456
tests/cli_e2e/okr/okr_shortcuts_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user