mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
5 Commits
coderabbit
...
feat/confi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
771a30a3b1 | ||
|
|
d7a83dfc79 | ||
|
|
5b050095ac | ||
|
|
960f6daabc | ||
|
|
2fcb703943 |
30
.github/workflows/semantic-review.yml
vendored
30
.github/workflows/semantic-review.yml
vendored
@@ -47,13 +47,10 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -74,11 +71,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("PR quality summary using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -126,7 +123,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("PR quality summary skipped: workflow_run is stale for this PR base");
|
||||
@@ -258,13 +255,10 @@ jobs:
|
||||
throw new Error(`ambiguous workflow_run pull request bindings: ${runPRs.length}`);
|
||||
}
|
||||
let prNumber = Number(runPRs[0]?.number || 0);
|
||||
const eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
let eventBaseSha = runPRs[0]?.base?.sha || "";
|
||||
const eventHeadSha = runPRs[0]?.head?.sha || "";
|
||||
const targetHeadSha = run.head_sha;
|
||||
const targetHeadSha = eventHeadSha || run.head_sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(targetHeadSha)) throw new Error("invalid PR head sha");
|
||||
if (eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
core.notice("semantic review using workflow_run head_sha because workflow_run pull request head differs from the CI run head");
|
||||
}
|
||||
|
||||
const factsArtifactPattern = /^quality-gate-facts-([a-f0-9]{40})-([a-f0-9]{40})$/i;
|
||||
const { data: artifactData } = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
@@ -285,11 +279,11 @@ jobs:
|
||||
if (artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()) {
|
||||
artifactError = "facts artifact head sha does not match verified PR head sha";
|
||||
factsArtifactName = "";
|
||||
} else if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
artifactError = "facts artifact base sha does not match workflow_run pull request base sha";
|
||||
factsArtifactName = "";
|
||||
} else {
|
||||
artifactBaseSha = parsedBaseSha;
|
||||
if (eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()) {
|
||||
core.notice("semantic review using facts artifact base because workflow_run pull request base differs from the CI facts artifact base");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!prNumber) {
|
||||
@@ -337,7 +331,7 @@ jobs:
|
||||
core.setOutput("stale", "true");
|
||||
return;
|
||||
}
|
||||
const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha;
|
||||
const baseSha = eventBaseSha || artifactBaseSha || pr.base.sha;
|
||||
if (!/^[a-f0-9]{40}$/i.test(baseSha)) throw new Error("invalid PR base sha");
|
||||
if ((eventBaseSha || artifactBaseSha) && pr.base.sha !== baseSha) {
|
||||
core.notice("semantic review skipped: workflow_run is stale for this PR base");
|
||||
|
||||
49
CHANGELOG.md
49
CHANGELOG.md
@@ -2,53 +2,6 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.57] - 2026-06-23
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: Add `+screenshot` to capture slide page images (or render a single `<slide>` XML snippet), returning the local file path instead of Base64 (#1358)
|
||||
- **base**: Support record comments (#1043)
|
||||
- **search**: Surface search API notices (#1413)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **mail**: Resolve folder/label filter once per `+triage list` call (#1512)
|
||||
- **meta**: Backfill enum value descriptions from options (#1541)
|
||||
- **cli**: Add missing CLI headers for git credential helper (#1539)
|
||||
|
||||
### Documentation
|
||||
|
||||
- **doc**: Refine rich block, path, and block ID guidance (#1508)
|
||||
- **mail**: Trim lark-mail skill context (#1527)
|
||||
- **drive**: Add permission governance workflow guidance (#1292)
|
||||
|
||||
### Build
|
||||
|
||||
- **ci**: Bind semantic review to workflow run head (#1551)
|
||||
|
||||
## [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
|
||||
@@ -1236,8 +1189,6 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57
|
||||
[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,12 +285,18 @@ func TestConfigInitRun_NonTerminal_NoFlags_RejectsWithHint(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-terminal without flags")
|
||||
}
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, "--new") {
|
||||
t.Errorf("expected error to mention --new, got: %s", msg)
|
||||
if !strings.Contains(err.Error(), "terminal") {
|
||||
t.Errorf("expected error to mention terminal, got: %s", err.Error())
|
||||
}
|
||||
if !strings.Contains(msg, "terminal") {
|
||||
t.Errorf("expected error to mention terminal, 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ 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
|
||||
|
||||
@@ -56,9 +63,11 @@ 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: 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.
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -81,6 +90,8 @@ 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)")
|
||||
@@ -132,7 +143,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
|
||||
return o.New || o.AppID != "" || o.AppSecretStdin || o.NoWait || o.DeviceCode != ""
|
||||
}
|
||||
|
||||
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
|
||||
@@ -308,6 +319,22 @@ 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)
|
||||
@@ -335,6 +362,15 @@ 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)
|
||||
@@ -437,9 +473,12 @@ func configInitRun(opts *ConfigInitOptions) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Non-terminal: cannot run interactive mode, guide user to --new
|
||||
// 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.
|
||||
if !f.IOStreams.IsTerminal {
|
||||
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.")
|
||||
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.")
|
||||
}
|
||||
|
||||
// Mode 5: Legacy interactive (readline fallback)
|
||||
|
||||
@@ -182,6 +182,11 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
265
cmd/config/init_nowait.go
Normal file
265
cmd/config/init_nowait.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// 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
|
||||
}
|
||||
116
cmd/config/init_nowait_cache.go
Normal file
116
cmd/config/init_nowait_cache.go
Normal file
@@ -0,0 +1,116 @@
|
||||
// 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[:])
|
||||
}
|
||||
521
cmd/config/init_nowait_test.go
Normal file
521
cmd/config/init_nowait_test.go
Normal file
@@ -0,0 +1,521 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -26,7 +26,6 @@ func TestRunList_TextOutput(t *testing.T) {
|
||||
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
|
||||
"im.message.receive_v1",
|
||||
"im.message.message_read_v1",
|
||||
"task.task.update_user_access_v2",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("list output missing %q; full output:\n%s", want, out)
|
||||
@@ -56,17 +55,4 @@ func TestRunList_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var foundTask bool
|
||||
for _, row := range rows {
|
||||
if row["key"] == "task.task.update_user_access_v2" {
|
||||
foundTask = true
|
||||
if row["single_consumer"] != true {
|
||||
t.Errorf("task row single_consumer = %v, want true", row["single_consumer"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundTask {
|
||||
t.Fatal("event list JSON missing task.task.update_user_access_v2")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,34 +96,6 @@ func TestRunSchema_JSONOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
|
||||
|
||||
if err := runSchema(f, "task.task.update_user_access_v2", true); err != nil {
|
||||
t.Fatalf("runSchema json: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if payload["jq_root_path"] != ".event" {
|
||||
t.Errorf("jq_root_path = %v, want .event", payload["jq_root_path"])
|
||||
}
|
||||
if payload["single_consumer"] != true {
|
||||
t.Errorf("single_consumer = %v, want true", payload["single_consumer"])
|
||||
}
|
||||
resolved := payload["resolved_output_schema"].(map[string]interface{})
|
||||
props := resolved["properties"].(map[string]interface{})
|
||||
eventProps := props["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
if got := eventProps["task_guid"].(map[string]interface{})["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
if _, ok := eventProps["event_types"].(map[string]interface{})["items"].(map[string]interface{})["enum"]; !ok {
|
||||
t.Fatalf("event_types enum missing in schema: %#v", eventProps["event_types"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) {
|
||||
const syntheticKey = "test.evt_sub"
|
||||
t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) })
|
||||
|
||||
@@ -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."`
|
||||
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."`
|
||||
}
|
||||
|
||||
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
|
||||
@@ -55,10 +55,8 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
|
||||
}
|
||||
|
||||
msg := envelope.Event.Message
|
||||
var content string
|
||||
if msg.MessageType == "interactive" {
|
||||
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
|
||||
} else {
|
||||
content := msg.Content
|
||||
if msg.MessageType != "interactive" {
|
||||
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
|
||||
RawContent: msg.Content,
|
||||
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),
|
||||
|
||||
@@ -7,7 +7,6 @@ package events
|
||||
import (
|
||||
"github.com/larksuite/cli/events/im"
|
||||
"github.com/larksuite/cli/events/minutes"
|
||||
"github.com/larksuite/cli/events/task"
|
||||
"github.com/larksuite/cli/events/vc"
|
||||
"github.com/larksuite/cli/events/whiteboard"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
@@ -18,7 +17,6 @@ func init() {
|
||||
all := [][]event.KeyDefinition{
|
||||
im.Keys(),
|
||||
minutes.Keys(),
|
||||
task.Keys(),
|
||||
vc.Keys(),
|
||||
whiteboard.Keys(),
|
||||
}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
// TaskUpdateUserAccessV2Data is the Task v2 update event payload under the
|
||||
// standard Lark V2 event envelope.
|
||||
type TaskUpdateUserAccessV2Data struct {
|
||||
EventTypes []string `json:"event_types,omitempty" desc:"Task commit types included in this event" enum:"task_create,task_deleted,task_summary_update,task_desc_update,task_assignees_update,task_followers_update,task_reminders_update,task_start_due_update,task_completed_update"`
|
||||
TaskGUID string `json:"task_guid,omitempty" desc:"Task GUID that changed" kind:"task_guid"`
|
||||
}
|
||||
|
||||
var taskUpdateUserAccessCommitTypes = []string{
|
||||
"task_create",
|
||||
"task_deleted",
|
||||
"task_summary_update",
|
||||
"task_desc_update",
|
||||
"task_assignees_update",
|
||||
"task_followers_update",
|
||||
"task_reminders_update",
|
||||
"task_start_due_update",
|
||||
"task_completed_update",
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const taskSubscriptionPath = "/open-apis/task/v2/task_v2/task_subscription?user_id_type=open_id"
|
||||
|
||||
func taskSubscriptionPreConsume(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) {
|
||||
if rt == nil {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"runtime API client is required for pre-consume subscription")
|
||||
}
|
||||
|
||||
if _, err := rt.CallAPI(ctx, "POST", taskSubscriptionPath, nil); err != nil {
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errs.NewNetworkError(
|
||||
errs.SubtypeNetworkTransport,
|
||||
"failed to subscribe task event",
|
||||
).WithCause(err)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
type stubAPIClient struct {
|
||||
err error
|
||||
|
||||
method string
|
||||
path string
|
||||
body interface{}
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
|
||||
s.method = method
|
||||
s.path = path
|
||||
s.body = body
|
||||
s.calls++
|
||||
if s.err != nil {
|
||||
return nil, s.err
|
||||
}
|
||||
return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeCallsSubscribeAPI(t *testing.T) {
|
||||
rt := &stubAPIClient{}
|
||||
cleanup, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("taskSubscriptionPreConsume error = %v", err)
|
||||
}
|
||||
if cleanup != nil {
|
||||
t.Fatal("cleanup = non-nil, want nil because task subscription has no unsubscribe API")
|
||||
}
|
||||
if rt.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", rt.calls)
|
||||
}
|
||||
if rt.method != "POST" {
|
||||
t.Errorf("method = %q, want POST", rt.method)
|
||||
}
|
||||
if rt.path != taskSubscriptionPath {
|
||||
t.Errorf("path = %q, want %q", rt.path, taskSubscriptionPath)
|
||||
}
|
||||
if rt.body != nil {
|
||||
t.Errorf("body = %#v, want nil", rt.body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeRequiresRuntime(t *testing.T) {
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), nil, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryInternal)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumePassesThroughAPIError(t *testing.T) {
|
||||
wantErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "subscription already exists")
|
||||
rt := &stubAPIClient{err: wantErr}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err != wantErr {
|
||||
t.Fatalf("err identity changed: got %T %v, want original %T %v", err, err, wantErr, wantErr)
|
||||
}
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("err = %v, want %v", err, wantErr)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskSubscriptionPreConsumeWrapsUntypedAPIError(t *testing.T) {
|
||||
cause := errors.New("connection reset")
|
||||
rt := &stubAPIClient{err: cause}
|
||||
|
||||
_, err := taskSubscriptionPreConsume(context.Background(), rt, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !errors.Is(err, cause) {
|
||||
t.Fatalf("err = %v, want cause %v", err, cause)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryNetwork {
|
||||
t.Errorf("category = %s, want %s", p.Category, errs.CategoryNetwork)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Errorf("subtype = %s, want %s", p.Subtype, errs.SubtypeNetworkTransport)
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package task registers Task-domain EventKeys.
|
||||
package task
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
)
|
||||
|
||||
const eventTypeTaskUpdateUserAccessV2 = "task.task.update_user_access_v2"
|
||||
|
||||
// Keys returns all Task-domain EventKey definitions.
|
||||
func Keys() []event.KeyDefinition {
|
||||
return []event.KeyDefinition{
|
||||
{
|
||||
Key: eventTypeTaskUpdateUserAccessV2,
|
||||
DisplayName: "Task updated",
|
||||
Description: "Triggered when tasks visible to the current user or app are created, deleted, or updated",
|
||||
EventType: eventTypeTaskUpdateUserAccessV2,
|
||||
Schema: event.SchemaDef{
|
||||
Native: &event.SchemaSpec{Type: reflect.TypeOf(TaskUpdateUserAccessV2Data{})},
|
||||
},
|
||||
PreConsume: taskSubscriptionPreConsume,
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
RequiredConsoleEvents: []string{eventTypeTaskUpdateUserAccessV2},
|
||||
SingleConsumer: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/event"
|
||||
"github.com/larksuite/cli/internal/event/schemas"
|
||||
)
|
||||
|
||||
func TestKeysTaskUpdateUserAccessMetadata(t *testing.T) {
|
||||
keys := Keys()
|
||||
if len(keys) != 1 {
|
||||
t.Fatalf("len(Keys()) = %d, want 1", len(keys))
|
||||
}
|
||||
|
||||
def := keys[0]
|
||||
if def.Key != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("Key = %q, want %q", def.Key, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.EventType != eventTypeTaskUpdateUserAccessV2 {
|
||||
t.Errorf("EventType = %q, want %q", def.EventType, eventTypeTaskUpdateUserAccessV2)
|
||||
}
|
||||
if def.Schema.Native == nil {
|
||||
t.Fatal("Schema.Native is nil")
|
||||
}
|
||||
if def.Schema.Native.Type != reflect.TypeOf(TaskUpdateUserAccessV2Data{}) {
|
||||
t.Errorf("native type = %v, want TaskUpdateUserAccessV2Data", def.Schema.Native.Type)
|
||||
}
|
||||
if def.Process != nil {
|
||||
t.Fatal("Native Task EventKey must not set Process")
|
||||
}
|
||||
if def.PreConsume == nil {
|
||||
t.Fatal("PreConsume is nil")
|
||||
}
|
||||
if !def.SingleConsumer {
|
||||
t.Fatal("SingleConsumer = false, want true")
|
||||
}
|
||||
if !reflect.DeepEqual(def.Scopes, []string{"task:task:read"}) {
|
||||
t.Errorf("Scopes = %#v", def.Scopes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.AuthTypes, []string{"user", "bot"}) {
|
||||
t.Errorf("AuthTypes = %#v", def.AuthTypes)
|
||||
}
|
||||
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{eventTypeTaskUpdateUserAccessV2}) {
|
||||
t.Errorf("RequiredConsoleEvents = %#v", def.RequiredConsoleEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessSchemaAnnotations(t *testing.T) {
|
||||
raw := schemas.WrapV2Envelope(schemas.FromType(reflect.TypeOf(TaskUpdateUserAccessV2Data{})))
|
||||
var schema map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
t.Fatalf("unmarshal schema: %v", err)
|
||||
}
|
||||
|
||||
eventProps := schema["properties"].(map[string]interface{})["event"].(map[string]interface{})["properties"].(map[string]interface{})
|
||||
taskGUID := eventProps["task_guid"].(map[string]interface{})
|
||||
if got := taskGUID["format"]; got != "task_guid" {
|
||||
t.Errorf("task_guid format = %v, want task_guid", got)
|
||||
}
|
||||
|
||||
eventTypes := eventProps["event_types"].(map[string]interface{})
|
||||
items := eventTypes["items"].(map[string]interface{})
|
||||
rawEnum, ok := items["enum"].([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("event_types item enum missing: %#v", items["enum"])
|
||||
}
|
||||
got := make(map[string]bool, len(rawEnum))
|
||||
for _, v := range rawEnum {
|
||||
got[v.(string)] = true
|
||||
}
|
||||
for _, want := range taskUpdateUserAccessCommitTypes {
|
||||
if !got[want] {
|
||||
t.Errorf("event_types enum missing %q; enum=%v", want, rawEnum)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskUpdateUserAccessRegistersCleanly(t *testing.T) {
|
||||
const key = eventTypeTaskUpdateUserAccessV2
|
||||
event.UnregisterKeyForTest(key)
|
||||
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
|
||||
|
||||
for _, def := range Keys() {
|
||||
event.RegisterKey(def)
|
||||
}
|
||||
if _, ok := event.Lookup(key); !ok {
|
||||
t.Fatalf("event.Lookup(%q) not registered", key)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package auth
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -13,9 +14,24 @@ 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
|
||||
@@ -63,7 +79,7 @@ func RequestAppRegistration(httpClient *http.Client, brand core.LarkBrand, errOu
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "app registration request failed: %v", err).WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
logHTTPResponse(resp)
|
||||
@@ -138,13 +154,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, fmt.Errorf("polling was cancelled")
|
||||
return nil, ErrAppRegCancelled
|
||||
}
|
||||
|
||||
select {
|
||||
case <-time.After(time.Duration(currentInterval) * time.Second):
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("polling was cancelled")
|
||||
return nil, ErrAppRegCancelled
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
@@ -205,9 +221,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, fmt.Errorf("app registration denied by user")
|
||||
return nil, ErrAppRegDenied
|
||||
case "expired_token", "invalid_grant":
|
||||
return nil, fmt.Errorf("device code expired, please try again")
|
||||
return nil, fmt.Errorf("%w, please try again", ErrAppRegExpired)
|
||||
}
|
||||
|
||||
desc := getStr(data, "error_description")
|
||||
@@ -223,5 +239,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("app registration timed out, please try again")
|
||||
return nil, fmt.Errorf("%w, please try again", ErrAppRegTimeout)
|
||||
}
|
||||
|
||||
95
internal/auth/app_registration_sentinel_test.go
Normal file
95
internal/auth/app_registration_sentinel_test.go
Normal file
@@ -0,0 +1,95 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -113,8 +113,7 @@ type EnumOption struct {
|
||||
}
|
||||
|
||||
// EnumOptions returns the field's allowed values paired with their descriptions
|
||||
// — from enum (with descriptions backfilled from options when the field carries
|
||||
// both forms), or from options when enum is absent — coerced to the canonical
|
||||
// — from enum, or from options when enum is absent — coerced to the canonical
|
||||
// type and ordered: numeric and boolean values are sorted; string values keep
|
||||
// source order (which can encode priority). Uncoercible literals are dropped.
|
||||
// Returns nil when the field declares no enum constraint.
|
||||
@@ -123,14 +122,9 @@ func (f Field) EnumOptions() []EnumOption {
|
||||
var out []EnumOption
|
||||
switch {
|
||||
case len(f.Enum) > 0:
|
||||
// key by raw literal so enum "1" and option 1 align across JSON types
|
||||
desc := make(map[string]string, len(f.Options))
|
||||
for _, o := range f.Options {
|
||||
desc[fmt.Sprintf("%v", o.Value)] = o.Description
|
||||
}
|
||||
for _, e := range f.Enum {
|
||||
if v, ok := coerceLiteral(ct, e); ok {
|
||||
out = append(out, EnumOption{Value: v, Description: desc[fmt.Sprintf("%v", e)]})
|
||||
out = append(out, EnumOption{Value: v})
|
||||
}
|
||||
}
|
||||
case len(f.Options) > 0:
|
||||
|
||||
@@ -80,39 +80,6 @@ func TestField_EnumOptions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_EnumOptions_BothEnumAndOptions(t *testing.T) {
|
||||
// enum is the value set; descriptions backfilled from options, empty where absent
|
||||
f := Field{Type: "string", Enum: []any{"1", "2", "3", "4", "6"}, Options: []Option{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
{Value: "6", Description: "subject"},
|
||||
}}
|
||||
want := []EnumOption{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
{Value: "3", Description: ""},
|
||||
{Value: "4", Description: ""},
|
||||
{Value: "6", Description: "subject"},
|
||||
}
|
||||
if got := f.EnumOptions(); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("EnumOptions(enum+options) = %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// enum values stored as strings match option values stored as numbers
|
||||
fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}, Options: []Option{
|
||||
{Value: 1, Description: "one"},
|
||||
{Value: 2, Description: "two"},
|
||||
}}
|
||||
wantI := []EnumOption{
|
||||
{Value: int64(1), Description: "one"},
|
||||
{Value: int64(2), Description: "two"},
|
||||
{Value: int64(10), Description: ""},
|
||||
}
|
||||
if got := fi.EnumOptions(); !reflect.DeepEqual(got, wantI) {
|
||||
t.Errorf("EnumOptions(integer enum+options) = %+v, want %+v", got, wantI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestField_Enum_NumberAndBoolean(t *testing.T) {
|
||||
// number: string-stored floats coerced to float64 and numerically sorted
|
||||
if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) {
|
||||
|
||||
@@ -472,18 +472,6 @@ func TestConvert_EnumDescriptions(t *testing.T) {
|
||||
if bare.EnumDescriptions != nil {
|
||||
t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions)
|
||||
}
|
||||
|
||||
// enum + options both present -> enumDescriptions backfilled, aligned, "" where absent
|
||||
both := Convert(meta.Field{Type: "string", Enum: []any{"1", "2", "3"}, Options: []meta.Option{
|
||||
{Value: "1", Description: "from"},
|
||||
{Value: "2", Description: "to"},
|
||||
}})
|
||||
if !reflect.DeepEqual(both.Enum, []interface{}{"1", "2", "3"}) {
|
||||
t.Errorf("both Enum = %v", both.Enum)
|
||||
}
|
||||
if !reflect.DeepEqual(both.EnumDescriptions, []string{"from", "to", ""}) {
|
||||
t.Errorf("both EnumDescriptions = %v, want [from to \"\"] aligned with enum", both.EnumDescriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMeta_AffordanceFromMethod(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.57",
|
||||
"version": "1.0.55",
|
||||
"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", encoding="utf-8", newline="\n") as fp:
|
||||
with open(OUT_PATH, "w") as fp:
|
||||
json.dump(data, fp, ensure_ascii=False, indent=2)
|
||||
fp.write("\n")
|
||||
|
||||
|
||||
@@ -179,10 +179,7 @@ fi
|
||||
require_in_step "$summary_verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "PR quality summary must verify the triggering workflow path"
|
||||
require_in_step "$summary_verify_step" 'run.event !== "pull_request"' "PR quality summary must only handle pull_request workflow_run events"
|
||||
require_in_step "$summary_verify_step" 'run.repository.id !== context.payload.repository.id' "PR quality summary must verify workflow_run repository id"
|
||||
require_in_step "$summary_verify_step" 'const targetHeadSha = run.head_sha' "PR quality summary must use the CI run head SHA as the verified PR head"
|
||||
require_in_step "$summary_verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "PR quality summary should tolerate mutable workflow_run PR head metadata"
|
||||
require_in_step "$summary_verify_step" 'factsArtifactPattern' "PR quality summary should use the base-bound facts artifact name when available"
|
||||
require_in_step "$summary_verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "PR quality summary must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$summary_verify_step" 'core.setOutput("artifact_error"' "PR quality summary must expose artifact binding failures"
|
||||
require_in_step "$summary_artifact_step" 'factsArtifactName' "PR quality summary artifact step must use the verified facts artifact binding"
|
||||
require_in_step "$summary_extract_facts_step" 'SEMANTIC_REVIEW_DECISION_OUT' "PR quality summary artifact verifier must write an infrastructure decision on verifier failure"
|
||||
@@ -201,9 +198,8 @@ require_in_step "$verify_step" 'workflowPath !== ".github/workflows/ci.yml"' "se
|
||||
require_in_step "$verify_step" 'run.repository.id !== context.payload.repository.id' "semantic-review must verify workflow_run repository id"
|
||||
require_in_step "$verify_step" 'run.event !== "pull_request"' "semantic-review must only handle pull_request workflow_run events"
|
||||
require_in_step "$verify_step" 'run.conclusion !== "success"' "semantic-review must only consume successful CI runs"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review should inspect workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = run.head_sha' "semantic-review target PR head must come from the completed CI run"
|
||||
require_in_step "$verify_step" 'eventHeadSha && eventHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR head metadata"
|
||||
require_in_step "$verify_step" 'const eventHeadSha = runPRs[0]?.head?.sha || ""' "semantic-review must prefer workflow_run PR head when GitHub provides it"
|
||||
require_in_step "$verify_step" 'const targetHeadSha = eventHeadSha || run.head_sha' "semantic-review target PR head must come from the workflow_run event"
|
||||
require_in_step "$verify_step" 'factsArtifactPattern' "semantic-review must use a base-bound facts artifact name"
|
||||
require_in_step "$verify_step" 'listWorkflowRunArtifacts' "semantic-review must read the workflow_run artifacts before resolving fallback base SHA"
|
||||
require_in_step "$verify_step" 'artifactHeadSha.toLowerCase() !== targetHeadSha.toLowerCase()' "semantic-review must not let the artifact choose a different PR head"
|
||||
@@ -214,8 +210,8 @@ require_in_step "$verify_step" 'commit_sha: targetHeadSha' "semantic-review fall
|
||||
require_in_step "$verify_step" 'github.rest.pulls.list' "semantic-review must have a pull-list fallback when commit association is empty"
|
||||
require_in_step "$verify_step" 'candidatePRs.length > 1' "semantic-review must fail closed when commit-to-PR fallback is ambiguous"
|
||||
require_in_step "$verify_step" 'pr.head.sha !== targetHeadSha' "semantic-review must skip stale PR heads"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review should tolerate mutable workflow_run PR base metadata"
|
||||
require_in_step "$verify_step" 'const baseSha = artifactBaseSha || eventBaseSha || pr.base.sha' "semantic-review must prefer the CI-time artifact base SHA"
|
||||
require_in_step "$verify_step" 'eventBaseSha && parsedBaseSha.toLowerCase() !== eventBaseSha.toLowerCase()' "semantic-review must reject mismatched event and artifact base SHAs"
|
||||
require_in_step "$verify_step" 'const baseSha = eventBaseSha || artifactBaseSha' "semantic-review fallback must use the CI-time artifact base SHA"
|
||||
require_in_step "$verify_step" 'pr.base.sha !== baseSha' "semantic-review must skip stale PR bases"
|
||||
require_in_step "$verify_step" 'core.setOutput("run_id"' "semantic-review must pass verified workflow run id to publisher"
|
||||
require_in_step "$verify_step" 'core.setOutput("head_repo_id"' "semantic-review must pass verified head repo id"
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
@@ -85,9 +84,6 @@ var AppsHTMLPublish = common.Shortcut{
|
||||
// for dry-run "advisory preview" semantics).
|
||||
dry.Set("validation_error", err.Error())
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
dry.Set("oversize_html", hits)
|
||||
}
|
||||
dry.Set("file_count", len(candidates))
|
||||
var totalSize int64
|
||||
names := make([]string, 0, len(candidates))
|
||||
@@ -144,22 +140,18 @@ type appsHTMLPublishSpec struct {
|
||||
// per-environment .env.* files for every stage).
|
||||
const maxSensitiveListInError = 5
|
||||
|
||||
// truncatedJoin joins items with ", ", capping at max entries and appending
|
||||
// "(and N more)" for the remainder, so an inline error list stays readable when
|
||||
// a payload has many hits.
|
||||
func truncatedJoin(items []string, max int) string {
|
||||
if len(items) <= max {
|
||||
return strings.Join(items, ", ")
|
||||
}
|
||||
return strings.Join(items[:max], ", ") + fmt.Sprintf(" (and %d more)", len(items)-max)
|
||||
}
|
||||
|
||||
// sensitiveCandidatesError builds the Validate-time rejection when --path
|
||||
// contains credential files and --allow-sensitive was not set.
|
||||
func sensitiveCandidatesError(hits []string) error {
|
||||
var sample string
|
||||
if len(hits) <= maxSensitiveListInError {
|
||||
sample = strings.Join(hits, ", ")
|
||||
} else {
|
||||
sample = strings.Join(hits[:maxSensitiveListInError], ", ") +
|
||||
fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError)
|
||||
}
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d credential file(s) that should not be published: %s",
|
||||
len(hits), truncatedJoin(hits, maxSensitiveListInError)).
|
||||
"--path contains %d credential file(s) that should not be published: %s", len(hits), sample).
|
||||
WithHint("remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)")
|
||||
}
|
||||
|
||||
@@ -176,30 +168,6 @@ var maxHTMLPublishTarballBytes int64 = 20 * 1024 * 1024
|
||||
// Mutable for tests.
|
||||
var maxHTMLPublishRawBytes int64 = 200 * 1024 * 1024
|
||||
|
||||
// maxHTMLPublishSingleHTMLFileBytes 单个 .html 文件上限,对齐妙搭服务端 10MB 约束。
|
||||
// 用 var 而非 const,便于单测调小覆盖拦截路径。
|
||||
var maxHTMLPublishSingleHTMLFileBytes int64 = 10 * 1024 * 1024
|
||||
|
||||
// oversizeHTMLFiles 返回 candidates 中扩展名为 .html(大小写不敏感)且单个 Size 超过
|
||||
// maxHTMLPublishSingleHTMLFileBytes 的 RelPath 列表。只针对 .html 文件,不波及图片/字体/JS。
|
||||
func oversizeHTMLFiles(candidates []htmlPublishCandidate) []string {
|
||||
var hits []string
|
||||
for _, c := range candidates {
|
||||
if strings.EqualFold(filepath.Ext(c.RelPath), ".html") && c.Size > maxHTMLPublishSingleHTMLFileBytes {
|
||||
hits = append(hits, c.RelPath)
|
||||
}
|
||||
}
|
||||
return hits
|
||||
}
|
||||
|
||||
// oversizeHTMLFilesError 构造单文件超限的 Validate 风格拒绝。
|
||||
func oversizeHTMLFilesError(hits []string) error {
|
||||
return appsValidationParamError("--path",
|
||||
"--path contains %d HTML file(s) exceeding the %d bytes (10MB) per-file limit: %s",
|
||||
len(hits), maxHTMLPublishSingleHTMLFileBytes, truncatedJoin(hits, maxSensitiveListInError)).
|
||||
WithHint("split or trim oversized HTML file(s); the 10MB cap applies to each single .html file")
|
||||
}
|
||||
|
||||
// ensureIndexHTML 要求 walker 抓到的 candidates 里必须含 index.html。
|
||||
// 目录形态:根目录下必须有 index.html。
|
||||
// 单文件形态:文件名必须就是 index.html。
|
||||
@@ -222,9 +190,6 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPu
|
||||
if err := ensureIndexHTML(candidates); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hits := oversizeHTMLFiles(candidates); len(hits) > 0 {
|
||||
return nil, oversizeHTMLFilesError(hits)
|
||||
}
|
||||
var rawTotal int64
|
||||
for _, c := range candidates {
|
||||
rawTotal += c.Size
|
||||
|
||||
@@ -503,82 +503,3 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) {
|
||||
t.Fatalf("client must not be called when raw cap hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizeHTMLFiles(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
cands := []htmlPublishCandidate{
|
||||
{RelPath: "index.html", Size: 50},
|
||||
{RelPath: "big.html", Size: 4096},
|
||||
{RelPath: "BIG.HTML", Size: 4096}, // 大小写不敏感
|
||||
{RelPath: "huge.png", Size: 9000}, // 非 .html,忽略
|
||||
}
|
||||
hits := oversizeHTMLFiles(cands)
|
||||
if len(hits) != 2 {
|
||||
t.Fatalf("hits=%v, want [big.html BIG.HTML]", hits)
|
||||
}
|
||||
for _, h := range hits {
|
||||
if h == "huge.png" || h == "index.html" {
|
||||
t.Fatalf("unexpected hit %q", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxHTMLPublishSingleHTMLFileBytes_Default(t *testing.T) {
|
||||
if maxHTMLPublishSingleHTMLFileBytes != 10*1024*1024 {
|
||||
t.Fatalf("default=%d, want %d (10MiB)", maxHTMLPublishSingleHTMLFileBytes, 10*1024*1024)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_RejectsOversizeHTMLFile(t *testing.T) {
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.html"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{}
|
||||
_, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir})
|
||||
if err == nil {
|
||||
t.Fatalf("expected per-file oversize error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if !strings.Contains(problem.Message, "big.html") || !strings.Contains(problem.Message, "10MB") {
|
||||
t.Fatalf("message=%q, want contains 'big.html' and '10MB'", problem.Message)
|
||||
}
|
||||
if problem.Hint == "" {
|
||||
t.Fatalf("expected non-empty hint")
|
||||
}
|
||||
if len(fake.calls) != 0 {
|
||||
t.Fatalf("client must not be called when an HTML file is oversize")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHTMLPublish_IgnoresOversizeNonHTML(t *testing.T) {
|
||||
// 单 .html 上限调小,但超限文件是 .png → 不被本护栏拦截,正常发布。
|
||||
orig := maxHTMLPublishSingleHTMLFileBytes
|
||||
maxHTMLPublishSingleHTMLFileBytes = 100
|
||||
defer func() { maxHTMLPublishSingleHTMLFileBytes = orig }()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<html></html>"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "big.png"), []byte(strings.Repeat("x", 4096)), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
fake := &fakeAppsHTMLPublishClient{resp: &htmlPublishResponse{URL: "https://miaoda/app_x"}}
|
||||
if _, err := runHTMLPublish(context.Background(), newTestFIO(), fake, appsHTMLPublishSpec{AppID: "app_x", Path: dir}); err != nil {
|
||||
t.Fatalf("non-html oversize must not be blocked by the .html cap: %v", err)
|
||||
}
|
||||
if len(fake.calls) != 1 {
|
||||
t.Fatalf("client should be called; calls=%v", fake.calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,14 +99,7 @@ var AppsInit = common.Shortcut{
|
||||
dry.Set("dir_error", err.Error())
|
||||
dir = defaultCloneDir(appID)
|
||||
} else if isAlreadyInitialized(dir) {
|
||||
if existing, e := ensureInitDirMatchesApp(dir, appID); e != nil {
|
||||
if existing != "" {
|
||||
dry.Set("app_id_mismatch", existing)
|
||||
}
|
||||
dry.Set("dir_error", e.Error())
|
||||
} else {
|
||||
dry.Set("already_initialized", true)
|
||||
}
|
||||
dry.Set("already_initialized", true)
|
||||
} else if e := ensureEmptyDir(dir); e != nil {
|
||||
dry.Set("dir_error", e.Error())
|
||||
}
|
||||
@@ -206,61 +199,6 @@ func isAlreadyInitialized(dir string) bool {
|
||||
return err == nil && !info.IsDir()
|
||||
}
|
||||
|
||||
// readMetaAppID 读取 <dir>/.spark/meta.json 的 app_id,用于判断目标目录是否同一个妙搭应用。
|
||||
// 返回 (appID, isSparkProject, err):
|
||||
// - meta.json 不存在 → ("", false, nil) 非妙搭工程
|
||||
// - 读取/解析失败(损坏/不可读) → ("", false, err) 无法确认是否妙搭工程
|
||||
// - 解析成功 → (trim 后的 app_id, true, nil)(app_id 缺失/为空时为 "")
|
||||
func readMetaAppID(dir string) (string, bool, error) {
|
||||
b, err := os.ReadFile(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths.
|
||||
if os.IsNotExist(err) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, appsFileIOError(err, "read %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
var m struct {
|
||||
AppID string `json:"app_id"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
return "", false, appsFileIOError(err, "parse %s failed: %v", metaRelPath, err)
|
||||
}
|
||||
return strings.TrimSpace(m.AppID), true, nil
|
||||
}
|
||||
|
||||
// ensureInitDirMatchesApp 校验「已存在的目标目录」能否被 appID 安全复用:
|
||||
// - 不是妙搭工程(无 meta.json) → nil(交给 ensureEmptyDir 判空/非空)
|
||||
// - 是妙搭工程且 app_id 与 appID 一致 → nil(走已初始化短路,复用本地代码)
|
||||
// - 是妙搭工程但 app_id 不一致(含为空) → 报错,提示换目录
|
||||
// - meta.json 损坏/不可读,无法确认 → 报错(fail closed),提示换目录
|
||||
//
|
||||
// 返回值 existing 是目录里已存在的 app_id(仅"已是另一个 app"的拒绝场景非空),供调用方在
|
||||
// dry-run 里回填 app_id_mismatch,避免二次读 meta.json。
|
||||
func ensureInitDirMatchesApp(dir, appID string) (existing string, err error) {
|
||||
existing, isSpark, readErr := readMetaAppID(dir)
|
||||
if readErr != nil {
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q already exists but its %s is unreadable or corrupted; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("choose a different --dir, or repair/remove the directory, before running +init").
|
||||
WithCause(readErr)
|
||||
}
|
||||
if !isSpark || existing == appID {
|
||||
return existing, nil
|
||||
}
|
||||
if existing == "" {
|
||||
// meta 存在但缺 app_id:更可能是同一应用上次 +init 中断留下的半成品,而非另一个 app。
|
||||
return "", appsValidationParamError("--dir",
|
||||
"target directory %q has a %s without an app_id; cannot confirm it belongs to app %s, refusing to use it",
|
||||
dir, metaRelPath, appID).
|
||||
WithHint("remove the directory and re-run +init, or choose a different --dir")
|
||||
}
|
||||
return existing, appsValidationParamError("--dir",
|
||||
"target directory %q is already initialized for a different app (%s); refusing to initialize app %s into it",
|
||||
dir, existing, appID).
|
||||
WithHint("choose a different --dir (or cd into the matching project) before running +init")
|
||||
}
|
||||
|
||||
// ensureMetaAppID patches <dir>/.spark/meta.json to include app_id when the file
|
||||
// exists but lacks (or has an empty) app_id. Other fields are preserved. When
|
||||
// the file does not exist, this is a no-op (we never create it).
|
||||
@@ -440,11 +378,6 @@ func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 异 app 目录护栏:拒绝把当前 app 初始化进另一个 app 的已初始化工程。
|
||||
if _, err := ensureInitDirMatchesApp(dir, appID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Already-initialized short-circuit: a dir containing .spark/meta.json is an
|
||||
// initialized app repo -> skip clone/scaffold/commit, but still refresh
|
||||
// the local env so a re-run picks up the latest startup env vars.
|
||||
|
||||
@@ -363,7 +363,7 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}}
|
||||
@@ -394,40 +394,6 @@ func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_AlreadyInitialized_AppIDMismatch(t *testing.T) {
|
||||
dir := relCloneDir(t)
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 目录是 app_other 的工程,却用 --app-id app_x 初始化 → 必须报错且不拉 env。
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"app_other"}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeCommandRunner{}
|
||||
withFakeRunner(t, f)
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("mismatched app_id must error")
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, err)
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--dir" {
|
||||
t.Fatalf("expected *errs.ValidationError with Param=--dir, got %T param=%v", err, ve)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") {
|
||||
t.Fatalf("message=%q, want 'different app'", problem.Message)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if containsAll(c, "+env-pull") || containsAll(c, "git", "clone") {
|
||||
t.Errorf("mismatch must not run env-pull/clone; got %v", f.calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsInit_HappyPathCleanTree(t *testing.T) {
|
||||
f := &fakeCommandRunner{results: map[string]fakeCallResult{
|
||||
"credential-init": credInitOK("http://u:t@h/app_x.git"),
|
||||
@@ -1502,125 +1468,6 @@ func TestAppsInit_Description_IsAboutCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadMetaAppID(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 不存在 meta.json → ("", false, nil)
|
||||
if got, ok, err := readMetaAppID(t.TempDir()); ok || got != "" || err != nil {
|
||||
t.Fatalf("no meta: got (%q,%v,%v), want (\"\",false,nil)", got, ok, err)
|
||||
}
|
||||
// 存在且有 app_id → (app_id, true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"app_id":"app_a"}`)); !ok || got != "app_a" || err != nil {
|
||||
t.Fatalf("with app_id: got (%q,%v,%v), want (\"app_a\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但 app_id 空 → ("", true, nil)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{"name":"x"}`)); !ok || got != "" || err != nil {
|
||||
t.Fatalf("empty app_id: got (%q,%v,%v), want (\"\",true,nil)", got, ok, err)
|
||||
}
|
||||
// 存在但坏 JSON → ("", false, err)(无法确认)
|
||||
if got, ok, err := readMetaAppID(writeMeta(t, `{not json`)); ok || got != "" || err == nil {
|
||||
t.Fatalf("bad json: got (%q,%v,err=%v), want (\"\",false,non-nil)", got, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureInitDirMatchesApp(t *testing.T) {
|
||||
writeMeta := func(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
// 无 meta(非妙搭工程)→ nil(交给 ensureEmptyDir)
|
||||
if _, err := ensureInitDirMatchesApp(t.TempDir(), "app_x"); err != nil {
|
||||
t.Fatalf("no meta should pass: %v", err)
|
||||
}
|
||||
// 同 app_id → (app_id, nil)(走已初始化短路)
|
||||
if existing, err := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_x"}`), "app_x"); err != nil || existing != "app_x" {
|
||||
t.Fatalf("same app should pass: existing=%q err=%v", existing, err)
|
||||
}
|
||||
|
||||
// 不同 app_id → error(换目录),返回 existing=app_other;断言 typed metadata(subtype/param)
|
||||
existing, errMismatch := ensureInitDirMatchesApp(writeMeta(t, `{"app_id":"app_other"}`), "app_x")
|
||||
if errMismatch == nil {
|
||||
t.Fatal("different app should error")
|
||||
}
|
||||
if existing != "app_other" {
|
||||
t.Fatalf("mismatch should return existing app_id, got %q", existing)
|
||||
}
|
||||
problem := requireAppsValidationProblem(t, errMismatch) // 已校验 Category==Validation
|
||||
if problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("subtype=%q, want %q", problem.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(errMismatch, &ve) {
|
||||
t.Fatalf("expected *errs.ValidationError, got %T", errMismatch)
|
||||
}
|
||||
if ve.Param != "--dir" {
|
||||
t.Fatalf("param=%q, want --dir", ve.Param)
|
||||
}
|
||||
if !strings.Contains(problem.Message, "different app") || !strings.Contains(problem.Message, "app_other") {
|
||||
t.Fatalf("message=%q, want 'different app' and 'app_other'", problem.Message)
|
||||
}
|
||||
if !strings.Contains(problem.Hint, "different --dir") {
|
||||
t.Fatalf("hint=%q, want 'different --dir'", problem.Hint)
|
||||
}
|
||||
|
||||
// 空 app_id(缺 app_id 标记的半成品)→ error,独立文案(非 "different app"),返回 existing=""
|
||||
emptyExisting, errEmpty := ensureInitDirMatchesApp(writeMeta(t, `{"name":"x"}`), "app_x")
|
||||
if errEmpty == nil {
|
||||
t.Fatal("empty meta app_id should error (cannot confirm same app)")
|
||||
}
|
||||
if emptyExisting != "" {
|
||||
t.Fatalf("empty app_id should return existing=\"\", got %q", emptyExisting)
|
||||
}
|
||||
pEmpty := requireAppsValidationProblem(t, errEmpty)
|
||||
if pEmpty.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("empty subtype=%q, want %q", pEmpty.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pEmpty.Message, "without an app_id") {
|
||||
t.Fatalf("empty app_id should have its own message, msg=%q", pEmpty.Message)
|
||||
}
|
||||
if strings.Contains(pEmpty.Message, "different app") {
|
||||
t.Fatalf("empty app_id must not reuse the different-app wording, msg=%q", pEmpty.Message)
|
||||
}
|
||||
|
||||
// meta 损坏/不可读 → error(fail closed),返回 existing=""
|
||||
badExisting, errBad := ensureInitDirMatchesApp(writeMeta(t, `{not json`), "app_x")
|
||||
if errBad == nil {
|
||||
t.Fatal("corrupted meta should fail closed")
|
||||
}
|
||||
if badExisting != "" {
|
||||
t.Fatalf("corrupted should return existing=\"\", got %q", badExisting)
|
||||
}
|
||||
pBad := requireAppsValidationProblem(t, errBad)
|
||||
if pBad.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("corrupted subtype=%q, want %q", pBad.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !strings.Contains(pBad.Message, "unreadable or corrupted") {
|
||||
t.Fatalf("corrupted meta msg=%q, want 'unreadable or corrupted'", pBad.Message)
|
||||
}
|
||||
var veBad *errs.ValidationError
|
||||
if !errors.As(errBad, &veBad) || veBad.Param != "--dir" {
|
||||
t.Fatalf("corrupted: expected ValidationError Param=--dir, got %T param=%v", errBad, veBad)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed
|
||||
// classification of an external-tool failure: a failing git subprocess
|
||||
// surfaces as internal/external_tool with the cause preserved.
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -33,7 +32,6 @@ import (
|
||||
)
|
||||
|
||||
const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info"
|
||||
const gitCredentialHelperReportedShortcut = appsService + ":+git-credential-helper"
|
||||
|
||||
// gitCredentialIssueHint is the actionable next-step attached to a failed
|
||||
// Git-credential issuance. A 5xx is flagged retryable separately at the call site.
|
||||
@@ -304,12 +302,7 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: issuePath(appID),
|
||||
}
|
||||
ctx = contextWithGitCredentialHelperShortcut(ctx)
|
||||
var opts []larkcore.RequestOptionFunc
|
||||
if optFn := cmdutil.ShortcutHeaderOpts(ctx); optFn != nil {
|
||||
opts = append(opts, optFn)
|
||||
}
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser, opts...)
|
||||
resp, err := ac.DoSDKRequest(ctx, req, core.AsUser)
|
||||
data, err := parseIssueCredentialData(resp, err, errclass.ClassifyContext{
|
||||
Brand: string(cfg.Brand),
|
||||
AppID: cfg.AppID,
|
||||
@@ -321,13 +314,6 @@ func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.
|
||||
return issuedFromData(appID, data)
|
||||
}
|
||||
|
||||
func contextWithGitCredentialHelperShortcut(ctx context.Context) context.Context {
|
||||
if _, ok := cmdutil.ShortcutNameFromContext(ctx); ok {
|
||||
return ctx
|
||||
}
|
||||
return cmdutil.ContextWithShortcut(ctx, gitCredentialHelperReportedShortcut, uuid.New().String())
|
||||
}
|
||||
|
||||
func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, action string) error {
|
||||
if f == nil || f.IOStreams == nil {
|
||||
return nil
|
||||
|
||||
@@ -825,7 +825,7 @@ func TestRunGitCredentialHelperActions(t *testing.T) {
|
||||
func TestFactoryIssuerBranches(t *testing.T) {
|
||||
factory, _, reg := newAppsExecuteFactory(t)
|
||||
expiresAt := time.Now().Add(24 * time.Hour).Unix()
|
||||
issueStub := &httpmock.Stub{
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/spark/v1/apps/app_xxx/git_info",
|
||||
Body: map[string]interface{}{
|
||||
@@ -836,8 +836,7 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
"StatusCode": 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(issueStub)
|
||||
})
|
||||
issued, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{})
|
||||
if err != nil {
|
||||
t.Fatalf("factory issuer returned error: %v", err)
|
||||
@@ -845,12 +844,6 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
if issued.PAT != "pat-token" {
|
||||
t.Fatalf("PAT = %q", issued.PAT)
|
||||
}
|
||||
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderShortcut); got != gitCredentialHelperReportedShortcut {
|
||||
t.Fatalf("%s = %q, want %q", cmdutil.HeaderShortcut, got, gitCredentialHelperReportedShortcut)
|
||||
}
|
||||
if got := issueStub.CapturedHeaders.Get(cmdutil.HeaderExecutionId); got == "" {
|
||||
t.Fatalf("%s header missing", cmdutil.HeaderExecutionId)
|
||||
}
|
||||
|
||||
factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") }
|
||||
if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil {
|
||||
@@ -887,20 +880,6 @@ func TestFactoryIssuerBranches(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextWithGitCredentialHelperShortcutPreservesExistingShortcut(t *testing.T) {
|
||||
ctx := cmdutil.ContextWithShortcut(context.Background(), "apps:+git-credential-init", "exec-existing")
|
||||
got := contextWithGitCredentialHelperShortcut(ctx)
|
||||
|
||||
name, ok := cmdutil.ShortcutNameFromContext(got)
|
||||
if !ok || name != "apps:+git-credential-init" {
|
||||
t.Fatalf("shortcut = %q ok=%v, want existing shortcut", name, ok)
|
||||
}
|
||||
executionID, ok := cmdutil.ExecutionIdFromContext(got)
|
||||
if !ok || executionID != "exec-existing" {
|
||||
t.Fatalf("execution id = %q ok=%v, want existing execution id", executionID, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCredentialHelpersAndParsers(t *testing.T) {
|
||||
if issuePath(" app/with space ") != "/open-apis/spark/v1/apps/app%2Fwith%20space/git_info" {
|
||||
t.Fatalf("issuePath escaped incorrectly: %s", issuePath(" app/with space "))
|
||||
|
||||
@@ -223,12 +223,6 @@ func (ctx *RuntimeContext) Float64(name string) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// IntArray returns an int-array flag value (repeated flag, also supports CSV splitting).
|
||||
func (ctx *RuntimeContext) IntArray(name string) []int {
|
||||
v, _ := ctx.Cmd.Flags().GetIntSlice(name)
|
||||
return v
|
||||
}
|
||||
|
||||
// StrArray returns a string-array flag value (repeated flag, no CSV splitting).
|
||||
func (ctx *RuntimeContext) StrArray(name string) []string {
|
||||
v, _ := ctx.Cmd.Flags().GetStringArray(name)
|
||||
@@ -1182,8 +1176,6 @@ func registerShortcutFlagsWithContext(ctx context.Context, cmd *cobra.Command, f
|
||||
var d float64
|
||||
fmt.Sscanf(fl.Default, "%g", &d)
|
||||
cmd.Flags().Float64(fl.Name, d, desc)
|
||||
case "int_array":
|
||||
cmd.Flags().IntSlice(fl.Name, nil, desc)
|
||||
case "string_array":
|
||||
cmd.Flags().StringArray(fl.Name, nil, desc)
|
||||
case "string_slice":
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -59,29 +56,3 @@ func TestRejectPositionalArgs_NoArgs(t *testing.T) {
|
||||
t.Fatalf("expected no error for empty args, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutFlagIntArray(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, nil)
|
||||
parent := &cobra.Command{Use: "root"}
|
||||
var got []int
|
||||
shortcut := Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+screenshot",
|
||||
Description: "capture screenshots",
|
||||
Flags: []Flag{
|
||||
{Name: "slide-number", Type: "int_array"},
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *RuntimeContext) error {
|
||||
got = runtime.IntArray("slide-number")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
shortcut.Mount(parent, f)
|
||||
parent.SetArgs([]string{"+screenshot", "--as", "user", "--slide-number", "1", "--slide-number", "2,3"})
|
||||
if err := parent.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if want := []int{1, 2, 3}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("slide-number = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ const (
|
||||
// Flag describes a CLI flag for a shortcut.
|
||||
type Flag struct {
|
||||
Name string // flag name (e.g. "calendar-id")
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "int_array" | "string_array" | "string_slice"
|
||||
Type string // "string" (default) | "bool" | "int" | "float64" | "string_array" | "string_slice"
|
||||
Default string // default value as string
|
||||
Desc string // help text
|
||||
Hidden bool // hidden from --help, still readable at runtime
|
||||
|
||||
@@ -85,7 +85,6 @@ type searchUserAPIData struct {
|
||||
Items []searchUserAPIItem `json:"items"`
|
||||
HasMore bool `json:"has_more"`
|
||||
PageToken string `json:"page_token"`
|
||||
Notice string `json:"notice"`
|
||||
}
|
||||
|
||||
type searchUserAPIItem struct {
|
||||
@@ -127,7 +126,6 @@ type searchUser struct {
|
||||
type searchUserResponse struct {
|
||||
Users []searchUser `json:"users"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
var ContactSearchUser = common.Shortcut{
|
||||
@@ -191,7 +189,6 @@ var ContactSearchUser = common.Shortcut{
|
||||
Execute: executeSearchUser,
|
||||
}
|
||||
|
||||
// executeSearchUser dispatches contact search to single-query or fanout mode.
|
||||
func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(runtime.Str("queries")) != "" {
|
||||
return executeSearchUserFanout(ctx, runtime)
|
||||
@@ -199,7 +196,6 @@ func executeSearchUser(ctx context.Context, runtime *common.RuntimeContext) erro
|
||||
return executeSearchUserSingle(ctx, runtime)
|
||||
}
|
||||
|
||||
// executeSearchUserSingle performs one contact search and preserves server notices.
|
||||
func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
body, err := buildSearchUserBody(runtime)
|
||||
if err != nil {
|
||||
@@ -226,7 +222,7 @@ func executeSearchUserSingle(ctx context.Context, runtime *common.RuntimeContext
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore, Notice: respData.Notice}
|
||||
out := searchUserResponse{Users: users, HasMore: hasMore}
|
||||
|
||||
runtime.OutFormat(out, &output.Meta{Count: len(users)}, func(w io.Writer) {
|
||||
if len(users) == 0 {
|
||||
|
||||
@@ -45,17 +45,22 @@ type fanoutResult struct {
|
||||
Query string
|
||||
Users []searchUser
|
||||
HasMore bool
|
||||
Notice string
|
||||
ErrMsg string // empty = success
|
||||
Err error // original failure, kept for typed all-failed propagation
|
||||
}
|
||||
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line.
|
||||
// isFanoutSummaryFormat gates the per-fanout stderr summary line. Includes csv
|
||||
// because that summary lives on stderr and never corrupts the csv stream on
|
||||
// stdout — single-query mode keeps the narrower isHumanReadableFormat predicate
|
||||
// for its refine hint, so adding csv here doesn't regress that path.
|
||||
func isFanoutSummaryFormat(format string) bool {
|
||||
return format == "pretty" || format == "table" || format == "csv"
|
||||
}
|
||||
|
||||
// runOneQuery converts one fanout request into either users or an error summary.
|
||||
// runOneQuery converts every failure mode (transport, HTTP status, parse,
|
||||
// API code) into an ErrMsg string instead of returning a Go error. The
|
||||
// fanout dispatcher (Task 6) relies on this so a single failed query never
|
||||
// short-circuits the remaining workers.
|
||||
func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int, query string,
|
||||
filter *searchUserAPIFilter) fanoutResult {
|
||||
// Pre-check ctx so queued workers see cancellation before issuing a
|
||||
@@ -89,10 +94,9 @@ func runOneQuery(ctx context.Context, runtime *common.RuntimeContext, index int,
|
||||
}
|
||||
|
||||
users, hasMore := projectUsers(respData, runtime.Str("lang"), runtime.Config.Brand)
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore, Notice: respData.Notice}
|
||||
return fanoutResult{Index: index, Query: query, Users: users, HasMore: hasMore}
|
||||
}
|
||||
|
||||
// fanoutErrorResult records a failed fanout query without stopping other workers.
|
||||
func fanoutErrorResult(index int, query string, err error) fanoutResult {
|
||||
if err == nil {
|
||||
return fanoutResult{Index: index, Query: query}
|
||||
@@ -109,16 +113,17 @@ type querySummary struct {
|
||||
Query string `json:"query"`
|
||||
Error string `json:"error,omitempty"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
type fanoutResponse struct {
|
||||
Users []fanoutUser `json:"users"`
|
||||
Queries []querySummary `json:"queries"`
|
||||
Notice string `json:"notice,omitempty"`
|
||||
}
|
||||
|
||||
// buildFanoutResponse flattens ordered fanout results and fails only when all queries fail.
|
||||
// buildFanoutResponse walks results by Index (input order), flattens users[]
|
||||
// with matched_query, lists every input in queries[] (including successes),
|
||||
// and returns an error only when every query failed. The error wraps the
|
||||
// first failing query's ErrMsg so the CLI exits non-zero on full failure.
|
||||
func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutResponse, error) {
|
||||
indexed := make([]fanoutResult, len(queries))
|
||||
for _, r := range results {
|
||||
@@ -137,7 +142,6 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
Query: queries[i],
|
||||
Error: r.ErrMsg,
|
||||
HasMore: r.HasMore,
|
||||
Notice: r.Notice,
|
||||
})
|
||||
if r.ErrMsg != "" {
|
||||
failed++
|
||||
@@ -148,9 +152,6 @@ func buildFanoutResponse(queries []string, results []fanoutResult) (*fanoutRespo
|
||||
}
|
||||
continue
|
||||
}
|
||||
if out.Notice == "" {
|
||||
out.Notice = r.Notice
|
||||
}
|
||||
for _, u := range r.Users {
|
||||
out.Users = append(out.Users, fanoutUser{searchUser: u, MatchedQuery: queries[i]})
|
||||
}
|
||||
|
||||
@@ -562,7 +562,6 @@ func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Fact
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// searchUserStub returns a representative user search response with a notice.
|
||||
func searchUserStub() *httpmock.Stub {
|
||||
return &httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -570,7 +569,6 @@ func searchUserStub() *httpmock.Stub {
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "ou_a",
|
||||
@@ -592,7 +590,6 @@ func searchUserStub() *httpmock.Stub {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchUser_Integration_PrettyRendersExpectedColumns verifies human output columns.
|
||||
func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
@@ -617,7 +614,6 @@ func TestSearchUser_Integration_PrettyRendersExpectedColumns(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchUser_Integration_JSONStructuredFields verifies normalized JSON and notices.
|
||||
func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(searchUserStub())
|
||||
@@ -635,9 +631,6 @@ func TestSearchUser_Integration_JSONStructuredFields(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatalf("envelope.data: expected object, got %v\nraw=%s", got["data"], stdout.String())
|
||||
}
|
||||
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("data.notice = %v", data["notice"])
|
||||
}
|
||||
users, _ := data["users"].([]interface{})
|
||||
if len(users) != 1 {
|
||||
t.Fatalf("users: expected 1, got %d (output=%s)", len(users), stdout.String())
|
||||
@@ -1365,7 +1358,6 @@ func TestSearchUser_Integration_NoAutoPaginationFlags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanout_FilterAppliedToEachQuery verifies shared fanout filters reach every request.
|
||||
func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
stub := &httpmock.Stub{
|
||||
@@ -1407,7 +1399,6 @@ func TestFanout_FilterAppliedToEachQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFanout_PartialFailure_ExitZero verifies partial fanout failures keep notices.
|
||||
func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, searchUserDefaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1415,7 +1406,6 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
BodyFilter: func(b []byte) bool { return strings.Contains(string(b), `"alice"`) },
|
||||
Body: map[string]interface{}{"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"items": []interface{}{map[string]interface{}{"id": "ou_a"}},
|
||||
"has_more": false,
|
||||
}},
|
||||
@@ -1442,17 +1432,10 @@ func TestFanout_PartialFailure_ExitZero(t *testing.T) {
|
||||
if len(users) != 1 {
|
||||
t.Errorf("users: expected 1 (alice), got %d; stdout=%s", len(users), stdout.String())
|
||||
}
|
||||
if data["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("data.notice = %v", data["notice"])
|
||||
}
|
||||
queries := data["queries"].([]interface{})
|
||||
if len(queries) != 2 {
|
||||
t.Fatalf("queries: expected 2, got %d", len(queries))
|
||||
}
|
||||
q0 := queries[0].(map[string]interface{})
|
||||
if q0["notice"] != "The query is too long and has been truncated to the first 50 characters for search." {
|
||||
t.Fatalf("queries[0].notice = %v", q0["notice"])
|
||||
}
|
||||
q1 := queries[1].(map[string]interface{})
|
||||
if !strings.HasPrefix(q1["error"].(string), "HTTP 500") {
|
||||
t.Errorf("queries[1].error: got %q", q1["error"])
|
||||
|
||||
@@ -74,9 +74,6 @@ var DocsSearch = common.Shortcut{
|
||||
"page_token": data["page_token"],
|
||||
"results": normalizedItems,
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
resultData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
|
||||
if len(normalizedItems) == 0 {
|
||||
|
||||
@@ -7,48 +7,8 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestDocsSearchExecutePassesThroughNotice verifies docs +search preserves notices.
|
||||
func TestDocsSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-search-notice"))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"res_units": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := mountAndRunDocs(t, DocsSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("DocsSearch.Execute() error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddIsoTimeFieldsSupportsJSONNumber verifies JSON numbers get ISO fields.
|
||||
func TestAddIsoTimeFieldsSupportsJSONNumber(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ const (
|
||||
var DriveAddComment = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+add-comment",
|
||||
Description: "Add a comment to doc/docx/file/sheet/slides/base(bitable); file targets support selected extensions and full comments only",
|
||||
Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only",
|
||||
Risk: "write",
|
||||
Scopes: []string{
|
||||
"drive:drive.metadata:readonly",
|
||||
@@ -131,12 +131,12 @@ var DriveAddComment = common.Shortcut{
|
||||
},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides/base/bitable URL, or wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides, bitable, base (required when --doc is a bare token; auto-detected for URLs; use bitable as the wire value, base is accepted as a compatibility alias)", Enum: []string{"doc", "docx", "file", "sheet", "slides", "bitable", "base"}},
|
||||
{Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true},
|
||||
{Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}},
|
||||
{Name: "content", Desc: "reply_elements JSON string", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"},
|
||||
{Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell>; for slides: <slide-block-type>!<xml-id>; for base(bitable): <table-id>!<record-id>!<view-id>"},
|
||||
{Name: "block-id", Desc: "for docx: anchor block ID; for sheet: <sheetId>!<cell> (e.g. a281f9!D6); for slides: <slide-block-type>!<xml-id> (e.g. shape!bPq)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
docRef, err := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
@@ -148,17 +148,6 @@ var DriveAddComment = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
if docRef.Kind == "base" {
|
||||
if runtime.Bool("full-comment") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--full-comment")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>").WithParam("--selection-with-ellipsis")
|
||||
}
|
||||
_, err := parseBaseCommentAnchor(runtime)
|
||||
return err
|
||||
}
|
||||
|
||||
// Sheet comment validation.
|
||||
if docRef.Kind == "sheet" {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
@@ -199,7 +188,7 @@ var DriveAddComment = common.Shortcut{
|
||||
return validateFileCommentMode(mode, "")
|
||||
}
|
||||
if mode == commentModeLocal && docRef.Kind == "doc" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -226,23 +215,6 @@ var DriveAddComment = common.Shortcut{
|
||||
resolvedToken = target.FileToken
|
||||
}
|
||||
|
||||
if resolvedKind == "base" {
|
||||
anchor, err := parseBaseCommentAnchor(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
commentBody := buildBaseCommentCreateV2Request(replyElements, anchor)
|
||||
desc := "1-step request: create base(bitable) record-local comment"
|
||||
if isWiki {
|
||||
desc = "2-step orchestration: resolve wiki -> create base(bitable) record-local comment"
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc(desc).
|
||||
POST("/open-apis/drive/v1/files/:file_token/new_comments").
|
||||
Body(commentBody).
|
||||
Set("file_token", resolvedToken)
|
||||
}
|
||||
|
||||
// Sheet comment dry-run.
|
||||
if resolvedKind == "sheet" {
|
||||
anchor, _ := parseSheetCellRef(blockID)
|
||||
@@ -380,14 +352,6 @@ var DriveAddComment = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Sheet comment: direct URL or token fast path.
|
||||
docRef, _ := parseCommentDocRef(runtime.Str("doc"), runtime.Str("type"))
|
||||
if docRef.Kind == "base" {
|
||||
return executeBaseComment(runtime, resolvedCommentTarget{
|
||||
DocID: docRef.Token,
|
||||
FileToken: docRef.Token,
|
||||
FileType: "base",
|
||||
ResolvedBy: "base",
|
||||
})
|
||||
}
|
||||
if docRef.Kind == "sheet" {
|
||||
return executeSheetComment(runtime, docRef)
|
||||
}
|
||||
@@ -411,9 +375,6 @@ var DriveAddComment = common.Shortcut{
|
||||
if target.FileType == "slides" {
|
||||
return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken})
|
||||
}
|
||||
if target.FileType == "base" {
|
||||
return executeBaseComment(runtime, target)
|
||||
}
|
||||
if target.FileType == "file" {
|
||||
return executeFileComment(runtime, target)
|
||||
}
|
||||
@@ -521,12 +482,6 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
if token, ok := extractURLToken(raw, "/sheets/"); ok {
|
||||
return commentDocRef{Kind: "sheet", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/base/"); ok {
|
||||
return commentDocRef{Kind: "base", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/bitable/"); ok {
|
||||
return commentDocRef{Kind: "base", Token: token}, nil
|
||||
}
|
||||
if token, ok := extractURLToken(raw, "/file/"); ok {
|
||||
return commentDocRef{Kind: "file", Token: token}, nil
|
||||
}
|
||||
@@ -540,7 +495,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
return commentDocRef{Kind: "doc", Token: token}, nil
|
||||
}
|
||||
if strings.Contains(raw, "://") {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides/base/bitable URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides/base(bitable)", raw).WithParam("--doc")
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw).WithParam("--doc")
|
||||
}
|
||||
if strings.ContainsAny(raw, "/?#") {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a token with --type, or a wiki URL", raw).WithParam("--doc")
|
||||
@@ -549,10 +504,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) {
|
||||
// Bare token: --type is required.
|
||||
docType = strings.TrimSpace(docType)
|
||||
if docType == "" {
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides, bitable, base; use bitable as the wire value, base is accepted as a compatibility alias)").WithParam("--type")
|
||||
}
|
||||
if docType == "bitable" || docType == "base" {
|
||||
return commentDocRef{Kind: "base", Token: raw}, nil
|
||||
return commentDocRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)").WithParam("--type")
|
||||
}
|
||||
return commentDocRef{Kind: docType, Token: raw}, nil
|
||||
}
|
||||
@@ -563,11 +515,11 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
return resolvedCommentTarget{}, err
|
||||
}
|
||||
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" || docRef.Kind == "base" {
|
||||
if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" {
|
||||
if mode == commentModeLocal {
|
||||
switch docRef.Kind {
|
||||
case "doc":
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, slides, and base(bitable); old doc format only supports full comments")
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "local comments only support docx, sheet, and slides; old doc format only supports full comments")
|
||||
case "file":
|
||||
if err := validateFileCommentMode(mode, ""); err != nil {
|
||||
return resolvedCommentTarget{}, err
|
||||
@@ -605,22 +557,6 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
if objType == "slides" && strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for slide comments; use --block-id <slide-block-type>!<xml-id>", objType)
|
||||
}
|
||||
if objType == "bitable" || objType == "base" {
|
||||
if runtime.Bool("full-comment") {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --full-comment is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--full-comment")
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("selection-with-ellipsis")) != "" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but --selection-with-ellipsis is not applicable for base(bitable) comments; use --block-id <table-id>!<record-id>!<view-id>", objType).WithParam("--selection-with-ellipsis")
|
||||
}
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to base: %s\n", common.MaskToken(objToken))
|
||||
return resolvedCommentTarget{
|
||||
DocID: objToken,
|
||||
FileToken: objToken,
|
||||
FileType: "base",
|
||||
ResolvedBy: "wiki",
|
||||
WikiToken: docRef.Token,
|
||||
}, nil
|
||||
}
|
||||
if objType == "sheet" {
|
||||
// Sheet comments are handled via the sheet fast path in Execute.
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -656,10 +592,10 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i
|
||||
}, nil
|
||||
}
|
||||
if mode == commentModeLocal && objType != "docx" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, slides, and base(bitable); for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>, for base use --block-id <table-id>!<record-id>!<view-id>", objType)
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id <sheetId>!<cell>, for slides use --block-id <slide-block-type>!<xml-id>", objType)
|
||||
}
|
||||
if mode == commentModeFull && objType != "docx" && objType != "doc" {
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides/base(bitable)", objType)
|
||||
return resolvedCommentTarget{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType)
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken))
|
||||
@@ -851,12 +787,6 @@ type sheetAnchor struct {
|
||||
Row int
|
||||
}
|
||||
|
||||
type baseAnchor struct {
|
||||
BlockID string
|
||||
BaseRecordID string
|
||||
BaseViewID string
|
||||
}
|
||||
|
||||
func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, replyElements []map[string]interface{}, sheet *sheetAnchor) map[string]interface{} {
|
||||
body := map[string]interface{}{
|
||||
"file_type": fileType,
|
||||
@@ -883,18 +813,6 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply
|
||||
return body
|
||||
}
|
||||
|
||||
func buildBaseCommentCreateV2Request(replyElements []map[string]interface{}, anchor baseAnchor) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"file_type": "bitable",
|
||||
"reply_elements": replyElements,
|
||||
"anchor": map[string]interface{}{
|
||||
"block_id": anchor.BlockID,
|
||||
"base_record_id": anchor.BaseRecordID,
|
||||
"base_view_id": anchor.BaseViewID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func anchorBlockIDForDryRun(blockID string) string {
|
||||
if strings.TrimSpace(blockID) != "" {
|
||||
return strings.TrimSpace(blockID)
|
||||
@@ -902,26 +820,6 @@ func anchorBlockIDForDryRun(blockID string) string {
|
||||
return "<anchor_block_id>"
|
||||
}
|
||||
|
||||
func parseBaseCommentAnchor(runtime *common.RuntimeContext) (baseAnchor, error) {
|
||||
blockID := strings.TrimSpace(runtime.Str("block-id"))
|
||||
if blockID == "" {
|
||||
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id is required for base(bitable) record-local comments (format: <table-id>!<record-id>!<view-id>, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R)").WithParam("--block-id")
|
||||
}
|
||||
return parseBaseBlockRef(blockID)
|
||||
}
|
||||
|
||||
func parseBaseBlockRef(blockID string) (baseAnchor, error) {
|
||||
parts := strings.Split(strings.TrimSpace(blockID), "!")
|
||||
if len(parts) != 3 || strings.TrimSpace(parts[0]) == "" || strings.TrimSpace(parts[1]) == "" || strings.TrimSpace(parts[2]) == "" {
|
||||
return baseAnchor{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "base(bitable) record-local comments require --block-id in <table-id>!<record-id>!<view-id> format, e.g. tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R").WithParam("--block-id")
|
||||
}
|
||||
return baseAnchor{
|
||||
BlockID: strings.TrimSpace(parts[0]),
|
||||
BaseRecordID: strings.TrimSpace(parts[1]),
|
||||
BaseViewID: strings.TrimSpace(parts[2]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseSlidesBlockRef(blockID string) (string, string, error) {
|
||||
blockID = strings.TrimSpace(blockID)
|
||||
if blockID == "" {
|
||||
@@ -1132,53 +1030,6 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeBaseComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
anchor, err := parseBaseCommentAnchor(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken))
|
||||
requestBody := buildBaseCommentCreateV2Request(replyElements, anchor)
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Creating base(bitable) record-local comment in %s (table=%s, record=%s, view=%s)\n",
|
||||
common.MaskToken(target.FileToken), anchor.BlockID, anchor.BaseRecordID, anchor.BaseViewID)
|
||||
|
||||
data, err := runtime.CallAPITyped("POST", requestPath, nil, requestBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out := map[string]interface{}{
|
||||
"file_token": target.FileToken,
|
||||
"file_type": "bitable",
|
||||
"resolved_by": target.ResolvedBy,
|
||||
"comment_mode": "base_record",
|
||||
"base_block_id": anchor.BlockID,
|
||||
"base_record_id": anchor.BaseRecordID,
|
||||
"base_view_id": anchor.BaseViewID,
|
||||
}
|
||||
if commentID := data["comment_id"]; commentID != nil {
|
||||
out["comment_id"] = commentID
|
||||
}
|
||||
if replyID := data["reply_id"]; replyID != nil {
|
||||
out["reply_id"] = replyID
|
||||
}
|
||||
if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil {
|
||||
out["created_at"] = createdAt
|
||||
}
|
||||
if target.WikiToken != "" {
|
||||
out["wiki_token"] = target.WikiToken
|
||||
}
|
||||
|
||||
runtime.Out(out, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error {
|
||||
replyElements, err := parseCommentReplyElements(runtime.Str("content"))
|
||||
if err != nil {
|
||||
|
||||
@@ -133,20 +133,6 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantKind: "file",
|
||||
wantToken: "fileToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type bitable",
|
||||
input: "baseToken",
|
||||
docType: "bitable",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken",
|
||||
},
|
||||
{
|
||||
name: "raw token with type base alias",
|
||||
input: "baseToken",
|
||||
docType: "base",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken",
|
||||
},
|
||||
{
|
||||
name: "raw token without type",
|
||||
input: "xxxxxx",
|
||||
@@ -170,18 +156,6 @@ func TestParseCommentDocRef(t *testing.T) {
|
||||
wantKind: "file",
|
||||
wantToken: "boxcn123",
|
||||
},
|
||||
{
|
||||
name: "base url",
|
||||
input: "https://example.larksuite.com/base/baseToken123?table=tbl1",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken123",
|
||||
},
|
||||
{
|
||||
name: "bitable url",
|
||||
input: "https://example.larksuite.com/bitable/baseToken456?table=tbl1",
|
||||
wantKind: "base",
|
||||
wantToken: "baseToken456",
|
||||
},
|
||||
{
|
||||
name: "unsupported url",
|
||||
input: "https://example.com/not-a-doc",
|
||||
@@ -752,35 +726,6 @@ func TestBuildCommentCreateV2RequestSheetOverridesBlockID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBaseCommentCreateV2Request(t *testing.T) {
|
||||
t.Parallel()
|
||||
replyElements := []map[string]interface{}{
|
||||
{"type": "text", "text": "base comment"},
|
||||
}
|
||||
got := buildBaseCommentCreateV2Request(replyElements, baseAnchor{
|
||||
BlockID: "tbl9mp6fj9kDKHQV",
|
||||
BaseRecordID: "recBIBgGmb",
|
||||
BaseViewID: "vewc46MG1R",
|
||||
})
|
||||
|
||||
if got["file_type"] != "bitable" {
|
||||
t.Fatalf("expected file_type bitable, got %#v", got["file_type"])
|
||||
}
|
||||
anchor, ok := got["anchor"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("expected anchor map, got %#v", got["anchor"])
|
||||
}
|
||||
if anchor["block_id"] != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("expected block_id tbl9mp6fj9kDKHQV, got %#v", anchor["block_id"])
|
||||
}
|
||||
if anchor["base_record_id"] != "recBIBgGmb" {
|
||||
t.Fatalf("expected base_record_id recBIBgGmb, got %#v", anchor["base_record_id"])
|
||||
}
|
||||
if anchor["base_view_id"] != "vewc46MG1R" {
|
||||
t.Fatalf("expected base_view_id vewc46MG1R, got %#v", anchor["base_view_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── Sheet cell ref parsing tests ────────────────────────────────────────────
|
||||
|
||||
func TestParseSheetCellRef(t *testing.T) {
|
||||
@@ -1040,78 +985,6 @@ func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateMissingBlockID(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--block-id is required") {
|
||||
t.Fatalf("expected block-id required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateMalformedBlockID(t *testing.T) {
|
||||
cases := []string{
|
||||
"tbl9mp6fj9kDKHQV",
|
||||
"tbl9mp6fj9kDKHQV!recBIBgGmb",
|
||||
"tbl9mp6fj9kDKHQV!!vewc46MG1R",
|
||||
}
|
||||
for _, blockID := range cases {
|
||||
t.Run(blockID, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", blockID,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "<table-id>!<record-id>!<view-id>") {
|
||||
t.Fatalf("expected block-id format error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentValidateRejectsIncompatibleFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "full comment",
|
||||
args: []string{"--full-comment"},
|
||||
wantErr: "--full-comment is not applicable for base(bitable) comments",
|
||||
},
|
||||
{
|
||||
name: "selection",
|
||||
args: []string{"--selection-with-ellipsis", "some text"},
|
||||
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
args := []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Slides comment execute tests ────────────────────────────────────────────
|
||||
|
||||
func TestSlidesCommentExecuteSuccess(t *testing.T) {
|
||||
@@ -1322,87 +1195,6 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
createStub := &httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/baseToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"comment_id": "baseComment123",
|
||||
"reply_id": "baseReply123",
|
||||
"created_at": 1700000000,
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(createStub)
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"请看这条记录"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var requestBody map[string]interface{}
|
||||
if err := json.Unmarshal(createStub.CapturedBody, &requestBody); err != nil {
|
||||
t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(createStub.CapturedBody))
|
||||
}
|
||||
if got := mustStringField(t, requestBody, "file_type", "request.file_type"); got != "bitable" {
|
||||
t.Fatalf("request file_type = %q, want bitable", got)
|
||||
}
|
||||
anchor := mustMapValue(t, requestBody["anchor"], "request.anchor")
|
||||
if got := mustStringField(t, anchor, "block_id", "request.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("request block_id = %q, want tbl9mp6fj9kDKHQV", got)
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_record_id", "request.anchor.base_record_id"); got != "recBIBgGmb" {
|
||||
t.Fatalf("request base_record_id = %q, want recBIBgGmb", got)
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_view_id", "request.anchor.base_view_id"); got != "vewc46MG1R" {
|
||||
t.Fatalf("request base_view_id = %q, want vewc46MG1R", got)
|
||||
}
|
||||
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "comment_mode", "data.comment_mode"); got != "base_record" {
|
||||
t.Fatalf("stdout comment_mode = %q, want base_record\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "reply_id", "data.reply_id"); got != "baseReply123" {
|
||||
t.Fatalf("stdout reply_id = %q, want baseReply123\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseCommentExecuteBareToken(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/baseBareToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "baseBareComment"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "baseBareToken",
|
||||
"--type", "bitable",
|
||||
"--content", `[{"type":"text","text":"ok"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "baseBareComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileCommentExecuteSuccess(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1641,40 +1433,6 @@ func TestDryRunSlidesDirectURL(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunBaseDirectURL(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/base/baseToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--dry-run", "--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "record-local comment") {
|
||||
t.Fatalf("dry-run output missing record-local comment: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
api := mustSliceValue(t, out["api"], "api")
|
||||
call := mustMapValue(t, api[0], "api[0]")
|
||||
body := mustMapValue(t, call["body"], "api[0].body")
|
||||
anchor := mustMapValue(t, body["anchor"], "api[0].body.anchor")
|
||||
if got := mustStringField(t, body, "file_type", "api[0].body.file_type"); got != "bitable" {
|
||||
t.Fatalf("dry-run body.file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "block_id", "api[0].body.anchor.block_id"); got != "tbl9mp6fj9kDKHQV" {
|
||||
t.Fatalf("dry-run body.anchor.block_id = %q, want tbl9mp6fj9kDKHQV\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_record_id", "api[0].body.anchor.base_record_id"); got != "recBIBgGmb" {
|
||||
t.Fatalf("dry-run body.anchor.base_record_id = %q, want recBIBgGmb\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, anchor, "base_view_id", "api[0].body.anchor.base_view_id"); got != "vewc46MG1R" {
|
||||
t.Fatalf("dry-run body.anchor.base_view_id = %q, want vewc46MG1R\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunWikiResolvesToSlides(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1878,92 +1636,25 @@ func TestResolveWikiToDocxFullComment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToBaseComment(t *testing.T) {
|
||||
for _, objType := range []string{"bitable", "base"} {
|
||||
t.Run(objType, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": objType, "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/drive/v1/files/bitToken/new_comments",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{"comment_id": "wikiBaseComment", "reply_id": "wikiBaseReply"},
|
||||
},
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--block-id", "tbl9mp6fj9kDKHQV!recBIBgGmb!vewc46MG1R",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "wikiBaseComment") {
|
||||
t.Fatalf("stdout missing comment_id: %s", stdout.String())
|
||||
}
|
||||
out := decodeJSONMap(t, stdout.String())
|
||||
data := mustMapValue(t, out["data"], "data")
|
||||
if got := mustStringField(t, data, "file_type", "data.file_type"); got != "bitable" {
|
||||
t.Fatalf("stdout file_type = %q, want bitable\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
if got := mustStringField(t, data, "wiki_token", "data.wiki_token"); got != "wikiToken" {
|
||||
t.Fatalf("stdout wiki_token = %q, want wikiToken\nstdout:\n%s", got, stdout.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveWikiToBaseRejectsIncompatibleFlags(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "full comment",
|
||||
args: []string{"--full-comment"},
|
||||
wantErr: "--full-comment is not applicable for base(bitable) comments",
|
||||
func TestResolveWikiToUnsupportedType(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selection",
|
||||
args: []string{"--selection-with-ellipsis", "some text"},
|
||||
wantErr: "--selection-with-ellipsis is not applicable for base(bitable) comments",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{"obj_type": "bitable", "obj_token": "bitToken"},
|
||||
},
|
||||
},
|
||||
})
|
||||
args := []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}
|
||||
args = append(args, tc.args...)
|
||||
err := mountAndRunDrive(t, DriveAddComment, args, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("expected %q error, got: %v", tc.wantErr, err)
|
||||
}
|
||||
})
|
||||
})
|
||||
err := mountAndRunDrive(t, DriveAddComment, []string{
|
||||
"+add-comment",
|
||||
"--doc", "https://example.larksuite.com/wiki/wikiToken",
|
||||
"--content", `[{"type":"text","text":"test"}]`,
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") {
|
||||
t.Fatalf("expected unsupported type error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2044,7 +1735,7 @@ func TestDocOldFormatLocalCommentRejected(t *testing.T) {
|
||||
"--block-id", "blk_123",
|
||||
"--as", "user",
|
||||
}, f, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, slides, and base(bitable)") {
|
||||
if err == nil || !strings.Contains(err.Error(), "only support docx, sheet, and slides") {
|
||||
t.Fatalf("expected local comment rejection for old doc, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,9 +149,6 @@ var DriveSearch = common.Shortcut{
|
||||
"page_token": data["page_token"],
|
||||
"results": normalizedItems,
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
resultData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(resultData, &output.Meta{Count: len(normalizedItems)}, func(w io.Writer) {
|
||||
renderDriveSearchTable(w, data, normalizedItems)
|
||||
|
||||
@@ -14,49 +14,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/errclass"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
// TestDriveSearchExecutePassesThroughNotice verifies drive +search preserves notices.
|
||||
func TestDriveSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/search/v2/doc_wiki/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"res_units": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := mountAndRunDrive(t, DriveSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("DriveSearch.Execute() error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClampOpenedTimeWindow covers opened-time clamping and slice notices.
|
||||
// TestClampOpenedTimeWindow covers the 3-month / 1-year boundary logic that
|
||||
// narrows --opened-since / --opened-until and generates the multi-slice notice.
|
||||
func TestClampOpenedTimeWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -26,7 +26,9 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// newTestRuntimeContext builds a RuntimeContext with string and bool test flags.
|
||||
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
|
||||
// command whose flags are populated from the provided string and bool maps,
|
||||
// for unit-testing shortcut bodies, validators, and dry-run shapes.
|
||||
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -57,38 +59,9 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// newChatSearchTestRuntimeContext builds a chat-search RuntimeContext with typed flags.
|
||||
func newChatSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
for name := range stringFlags {
|
||||
if name == "page-size" {
|
||||
continue
|
||||
}
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
for name, val := range stringFlags {
|
||||
if err := cmd.Flags().Set(name, val); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
for name, val := range boolFlags {
|
||||
if err := cmd.Flags().Set(name, map[bool]string{true: "true", false: "false"}[val]); err != nil {
|
||||
t.Fatalf("Flags().Set(%q) error = %v", name, err)
|
||||
}
|
||||
}
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// newMessagesSearchTestRuntimeContext builds a messages-search RuntimeContext.
|
||||
// newMessagesSearchTestRuntimeContext is the messages-search variant of
|
||||
// newTestRuntimeContext: registers the search-specific --page-size flag
|
||||
// before applying caller-provided values.
|
||||
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -258,7 +231,6 @@ func TestIsMediaKey(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutValidateBranches covers direct shortcut validation branches.
|
||||
func TestShortcutValidateBranches(t *testing.T) {
|
||||
|
||||
t.Run("ImChatCreate valid", func(t *testing.T) {
|
||||
@@ -325,7 +297,7 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch invalid page size", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "ok",
|
||||
"page-size": "0",
|
||||
}, nil)
|
||||
@@ -335,13 +307,12 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch allows long query for server-side notice", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
"query": strings.Repeat("q", 81),
|
||||
"page-size": "20",
|
||||
t.Run("ImChatSearch query too long", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": strings.Repeat("q", 65),
|
||||
}, nil)
|
||||
err := ImChatSearch.Validate(context.Background(), runtime)
|
||||
if err != nil {
|
||||
if err == nil || !strings.Contains(err.Error(), "--query exceeds the maximum of 64 characters") {
|
||||
t.Fatalf("ImChatSearch.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
@@ -469,29 +440,6 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend audio rejects non-opus local file", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"audio": "./voice.mp3",
|
||||
}, nil)
|
||||
err := ImMessagesSend.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
|
||||
t.Fatalf("ImMessagesSend.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend audio accepts opus and ogg local files", func(t *testing.T) {
|
||||
for _, audio := range []string{"./voice.opus", "./voice.ogg"} {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
"audio": audio,
|
||||
}, nil)
|
||||
if err := ImMessagesSend.Validate(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSend.Validate(%q) unexpected error = %v", audio, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesSend conflicting explicit msg-type", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"chat-id": "oc_123",
|
||||
@@ -515,17 +463,6 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImMessagesReply audio rejects non-opus local file", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"message-id": "om_123",
|
||||
"audio": "./voice.mp3",
|
||||
}, nil)
|
||||
err := ImMessagesReply.Validate(context.Background(), runtime)
|
||||
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
|
||||
t.Fatalf("ImMessagesReply.Validate() error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImThreadsMessagesList invalid thread", func(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"thread": "bad_thread",
|
||||
@@ -670,7 +607,6 @@ func TestShortcutValidateBranches(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestMessagesSearchPaginationConfig verifies page-all and page-limit behavior.
|
||||
func TestMessagesSearchPaginationConfig(t *testing.T) {
|
||||
t.Run("default single page", func(t *testing.T) {
|
||||
runtime := newMessagesSearchTestRuntimeContext(t, nil, nil)
|
||||
@@ -714,7 +650,8 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcutDryRunShapes verifies shortcut dry-run API paths and payloads.
|
||||
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
|
||||
// produces the expected API path, query parameters, and request body.
|
||||
func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
@@ -737,19 +674,19 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run includes built params", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
"page-size": "50",
|
||||
"page-token": "next_page",
|
||||
}, nil)
|
||||
got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":50`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
|
||||
if !strings.Contains(got, `"/open-apis/im/v2/chats/search"`) || !strings.Contains(got, `"page_size":20`) || !strings.Contains(got, `"query":"\"team-alpha\""`) {
|
||||
t.Fatalf("ImChatSearch.DryRun() = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ImChatSearch dry run still works with --exclude-muted set", func(t *testing.T) {
|
||||
runtime := newChatSearchTestRuntimeContext(t, map[string]string{
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"query": "team-alpha",
|
||||
}, map[string]bool{
|
||||
"exclude-muted": true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,993 +0,0 @@
|
||||
// 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"
|
||||
want := "hello @Alice [docs](https://example.com)\n[Image: img_123]"
|
||||
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)
|
||||
}
|
||||
// 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))
|
||||
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())
|
||||
}
|
||||
parts = append(parts, line.String())
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(strings.Join(parts, "\n"))
|
||||
@@ -58,17 +58,6 @@ 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
|
||||
@@ -125,14 +114,10 @@ func renderPostElem(el map[string]interface{}) string {
|
||||
var rendered string
|
||||
switch {
|
||||
case userId == "@_all" || userId == "all":
|
||||
rendered = `<at user_id="all"></at>`
|
||||
rendered = "@all"
|
||||
default:
|
||||
if name, _ := el["user_name"].(string); name != "" {
|
||||
if userId != "" && strings.HasPrefix(userId, "ou") {
|
||||
rendered = fmt.Sprintf(`<at user_id="%s">%s</at>`, userId, name)
|
||||
} else {
|
||||
rendered = "@" + name
|
||||
}
|
||||
rendered = "@" + name
|
||||
} else {
|
||||
rendered = "@" + userId
|
||||
}
|
||||
@@ -153,7 +138,7 @@ func renderPostElem(el map[string]interface{}) string {
|
||||
case "img":
|
||||
key, _ := el["image_key"].(string)
|
||||
if key != "" {
|
||||
return fmt.Sprintf("", key)
|
||||
return fmt.Sprintf("[Image: %s]", key)
|
||||
}
|
||||
return "[Image]"
|
||||
case "media":
|
||||
|
||||
@@ -93,13 +93,9 @@ 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: `<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: "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: "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"},
|
||||
@@ -148,87 +144,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1048,42 +1048,6 @@ func detectIMFileType(filePath string) string {
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
audioMessageInputDesc = "audio file key (file_xxx), URL, or cwd-relative local path for a voice message (absolute paths and .. are rejected); local paths and URLs must be Opus (.opus or Ogg Opus .ogg). For mp3/wav, convert to .opus first, or use --file to send as an attachment"
|
||||
audioMessageHint = "Convert non-Opus audio to .opus and use --audio for a voice message, for example: ffmpeg -i input.mp3 -acodec libopus -ac 1 -ar 16000 output.opus. To send the original mp3/wav as an attachment, use --file instead."
|
||||
)
|
||||
|
||||
func validateAudioMessageInput(flagName, value string) error {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || isMediaKey(value) {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := audioInputExt(value)
|
||||
if ext == "" {
|
||||
return nil
|
||||
}
|
||||
if ext == ".opus" || ext == ".ogg" {
|
||||
return nil
|
||||
}
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"%s supports only Opus audio files for audio messages, such as .opus files or Ogg Opus (.ogg) files",
|
||||
flagName,
|
||||
).WithParam(flagName).WithHint("%s", audioMessageHint)
|
||||
}
|
||||
|
||||
func audioInputExt(value string) string {
|
||||
if isURL(value) {
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(path.Ext(parsed.Path))
|
||||
}
|
||||
return strings.ToLower(filepath.Ext(value))
|
||||
}
|
||||
|
||||
const maxImageUploadSize = 5 * 1024 * 1024 // 5MB — Lark API limit for images
|
||||
const maxFileUploadSize = 100 * 1024 * 1024 // 100MB — Lark API limit for files
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -51,56 +50,6 @@ func TestDetectIMFileType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAudioMessageInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty", value: ""},
|
||||
{name: "existing file key", value: "file_abc"},
|
||||
{name: "opus file", value: "./voice.opus"},
|
||||
{name: "ogg opus file", value: "./voice.ogg"},
|
||||
{name: "uppercase opus", value: "./VOICE.OPUS"},
|
||||
{name: "mp3 local file", value: "./voice.mp3", wantErr: true},
|
||||
{name: "wav local file", value: "./voice.wav", wantErr: true},
|
||||
{name: "extensionless local path", value: "./voice"},
|
||||
{name: "opus url", value: "https://example.com/voice.opus?download=1"},
|
||||
{name: "ogg url", value: "https://example.com/voice.ogg?download=1"},
|
||||
{name: "mp3 url", value: "https://example.com/voice.mp3?download=1", wantErr: true},
|
||||
{name: "extensionless url", value: "https://example.com/download?id=1"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateAudioMessageInput("--audio", tt.value)
|
||||
if tt.wantErr {
|
||||
if err == nil || !strings.Contains(err.Error(), "--audio supports only Opus audio files") {
|
||||
t.Fatalf("validateAudioMessageInput(%q) error = %v", tt.value, err)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("validateAudioMessageInput(%q) error is not typed: %v", tt.value, err)
|
||||
}
|
||||
if p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("ProblemOf(%q) = category %q subtype %q", tt.value, p.Category, p.Subtype)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--audio" {
|
||||
t.Fatalf("validateAudioMessageInput(%q) param = %q, want --audio", tt.value, validationErr.Param)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "use --file") || !strings.Contains(p.Hint, "ffmpeg") {
|
||||
t.Fatalf("validateAudioMessageInput(%q) hint = %q, want --file and ffmpeg guidance", tt.value, p.Hint)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("validateAudioMessageInput(%q) unexpected error = %v", tt.value, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitCSV covers the shared helper that replaced the three identical functions
|
||||
func TestSplitCSV(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
||||
@@ -29,7 +29,7 @@ var ImChatSearch = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "query", Desc: "search keyword (server may return data.notice for overly long input)"},
|
||||
{Name: "query", Desc: "search keyword (max 64 chars)"},
|
||||
{Name: "search-types", Desc: "chat types, comma-separated (private, external, public_joined, public_not_joined)"},
|
||||
{Name: "chat-modes", Desc: "filter by chat mode, comma-separated (group, topic)"},
|
||||
{Name: "member-ids", Desc: "filter by member open_ids, comma-separated"},
|
||||
@@ -50,7 +50,7 @@ var ImChatSearch = common.Shortcut{
|
||||
Params(params).
|
||||
Body(body)
|
||||
},
|
||||
// Validate enforces query/member-ids presence, search-types
|
||||
// Validate enforces query/member-ids presence, --query rune cap, search-types
|
||||
// enum, --member-ids count and format, and --page-size bounds.
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
query := runtime.Str("query")
|
||||
@@ -58,6 +58,9 @@ var ImChatSearch = common.Shortcut{
|
||||
if query == "" && memberIDs == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query and --member-ids cannot both be empty; provide at least one (e.g. --query \"team-name\" or --member-ids \"ou_xxx\")")
|
||||
}
|
||||
if query != "" && len([]rune(query)) > 64 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--query exceeds the maximum of 64 characters (got %d)", len([]rune(query))).WithParam("--query")
|
||||
}
|
||||
if st := runtime.Str("search-types"); st != "" {
|
||||
allowed := map[string]struct{}{
|
||||
"private": {},
|
||||
@@ -148,9 +151,6 @@ var ImChatSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
}
|
||||
if notice, _ := resData["notice"].(string); notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
if mfOut.Meta.Applied != "" {
|
||||
outData["filter"] = MuteFilterMetaToMap(mfOut.Meta)
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ var ImMessagesReply = common.Shortcut{
|
||||
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
|
||||
{Name: "audio", Desc: audioMessageInputDesc},
|
||||
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "reply-in-thread", Type: "bool", Desc: "reply in thread (message appears in thread stream instead of main chat)"},
|
||||
{Name: "idempotency-key", Desc: "idempotency key (prevents duplicate sends)"},
|
||||
},
|
||||
@@ -100,9 +100,6 @@ var ImMessagesReply = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateAudioMessageInput("--audio", audioKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if messageId == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--message-id is required (om_xxx)").WithParam("--message-id")
|
||||
|
||||
@@ -91,7 +91,7 @@ var ImMessagesSearch = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, notice, err := searchMessages(runtime, req)
|
||||
rawItems, hasMore, nextPageToken, truncatedByLimit, pageLimit, err := searchMessages(runtime, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -103,9 +103,6 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "No matching messages found.")
|
||||
})
|
||||
@@ -134,9 +131,6 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"page_token": nextPageToken,
|
||||
"note": "failed to fetch message details, returning ID list only",
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d messages (failed to fetch details):\n", len(messageIds))
|
||||
for _, id := range messageIds {
|
||||
@@ -212,9 +206,6 @@ var ImMessagesSearch = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
if len(enriched) == 0 {
|
||||
fmt.Fprintln(w, "No matching messages found.")
|
||||
@@ -386,7 +377,6 @@ func buildMessagesSearchRequest(runtime *common.RuntimeContext) (*messagesSearch
|
||||
}, nil
|
||||
}
|
||||
|
||||
// messagesSearchPaginationConfig derives auto-pagination mode and page limit.
|
||||
func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginate bool, pageLimit int) {
|
||||
autoPaginate = runtime.Bool("page-all")
|
||||
if runtime.Cmd != nil && runtime.Cmd.Flags().Changed("page-limit") {
|
||||
@@ -402,8 +392,7 @@ func messagesSearchPaginationConfig(runtime *common.RuntimeContext) (autoPaginat
|
||||
return autoPaginate, pageLimit
|
||||
}
|
||||
|
||||
// searchMessages fetches message search pages and returns the first server notice.
|
||||
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, string, error) {
|
||||
func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest) ([]interface{}, bool, string, bool, int, error) {
|
||||
autoPaginate, pageLimit := messagesSearchPaginationConfig(runtime)
|
||||
pageToken := ""
|
||||
if tokens := req.params["page_token"]; len(tokens) > 0 {
|
||||
@@ -421,7 +410,6 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
lastPageToken string
|
||||
truncatedByLimit bool
|
||||
pageCount int
|
||||
notice string
|
||||
)
|
||||
|
||||
for {
|
||||
@@ -435,12 +423,9 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
|
||||
searchData, err := runtime.DoAPIJSONTyped(http.MethodPost, "/open-apis/im/v1/messages/search", params, req.body)
|
||||
if err != nil {
|
||||
return nil, false, "", false, pageLimit, "", err
|
||||
return nil, false, "", false, pageLimit, err
|
||||
}
|
||||
|
||||
if notice == "" {
|
||||
notice, _ = searchData["notice"].(string)
|
||||
}
|
||||
items, _ := searchData["items"].([]interface{})
|
||||
allItems = append(allItems, items...)
|
||||
lastHasMore, lastPageToken = common.PaginationMeta(searchData)
|
||||
@@ -456,10 +441,9 @@ func searchMessages(runtime *common.RuntimeContext, req *messagesSearchRequest)
|
||||
pageToken = lastPageToken
|
||||
}
|
||||
|
||||
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, notice, nil
|
||||
return allItems, lastHasMore, lastPageToken, truncatedByLimit, pageLimit, nil
|
||||
}
|
||||
|
||||
// batchMGetMessages fetches message details in API-sized batches.
|
||||
func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]interface{}, error) {
|
||||
var items []interface{}
|
||||
for _, batch := range chunkStrings(messageIds, messagesSearchMGetBatchSize) {
|
||||
@@ -473,7 +457,6 @@ func batchMGetMessages(runtime *common.RuntimeContext, messageIds []string) ([]i
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// batchQueryChatContexts fetches chat metadata best-effort for message rows.
|
||||
func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) map[string]map[string]interface{} {
|
||||
chatContexts := map[string]map[string]interface{}{}
|
||||
// Best-effort: a failed chunk only loses its own entries.
|
||||
@@ -483,7 +466,6 @@ func batchQueryChatContexts(runtime *common.RuntimeContext, chatIds []string) ma
|
||||
return chatContexts
|
||||
}
|
||||
|
||||
// chunkStrings splits a string slice into fixed-size batches.
|
||||
func chunkStrings(items []string, chunkSize int) [][]string {
|
||||
if len(items) == 0 || chunkSize <= 0 {
|
||||
return nil
|
||||
|
||||
@@ -37,7 +37,7 @@ var ImMessagesSend = common.Shortcut{
|
||||
{Name: "file", Desc: "file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
{Name: "video", Desc: "video file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); must be used together with --video-cover"},
|
||||
{Name: "video-cover", Desc: "video cover image key (img_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected); required when using --video"},
|
||||
{Name: "audio", Desc: audioMessageInputDesc},
|
||||
{Name: "audio", Desc: "audio file key (file_xxx), URL, or cwd-relative local path (absolute paths and .. are rejected)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
chatFlag := runtime.Str("chat-id")
|
||||
@@ -112,9 +112,6 @@ var ImMessagesSend = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := validateAudioMessageInput("--audio", audioKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := common.ExactlyOneTyped(runtime, "chat-id", "user-id"); err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package im
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestImChatSearchExecutePassesThroughNotice verifies chat search notice output.
|
||||
func TestImChatSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
longQuery := strings.Repeat("q", 81)
|
||||
|
||||
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v2/chats/search") {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
var body map[string]interface{}
|
||||
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
||||
return nil, fmt.Errorf("decode request body: %w", err)
|
||||
}
|
||||
if got, _ := body["query"].(string); got != longQuery {
|
||||
return nil, fmt.Errorf("body.query = %q, want %q", got, longQuery)
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
runtime.Cmd = newChatSearchNoticeTestCommand(t, longQuery)
|
||||
runtime.Format = "json"
|
||||
|
||||
if err := ImChatSearch.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImChatSearch.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, runtime)
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImMessagesSearchExecutePassesThroughNotice verifies message search notice output.
|
||||
func TestImMessagesSearchExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
runtime := newMessagesSearchRuntime(t, map[string]string{
|
||||
"query": "incident",
|
||||
}, nil, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/search") {
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
|
||||
}
|
||||
return shortcutJSONResponse(200, map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
}), nil
|
||||
}))
|
||||
runtime.Format = "json"
|
||||
|
||||
if err := ImMessagesSearch.Execute(context.Background(), runtime); err != nil {
|
||||
t.Fatalf("ImMessagesSearch.Execute() error = %v", err)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, runtime)
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// newChatSearchNoticeTestCommand builds a typed chat-search command for notice tests.
|
||||
func newChatSearchNoticeTestCommand(t *testing.T, query string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, name := range []string{"query", "search-types", "member-ids", "sort-by", "page-token"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for _, name := range []string{"is-manager", "disable-search-by-user", "exclude-muted"} {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
}
|
||||
cmd.Flags().Int("page-size", 20, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
if err := cmd.Flags().Set("query", query); err != nil {
|
||||
t.Fatalf("Flags().Set(query) error = %v", err)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// decodeShortcutData extracts the JSON envelope data object from shortcut output.
|
||||
func decodeShortcutData(t *testing.T, runtime *common.RuntimeContext) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
out, ok := runtime.Factory.IOStreams.Out.(*bytes.Buffer)
|
||||
if !ok {
|
||||
t.Fatalf("stdout buffer has type %T", runtime.Factory.IOStreams.Out)
|
||||
}
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, out.String())
|
||||
}
|
||||
data, ok := env["data"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("envelope data missing or wrong type: %#v", env)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -159,7 +159,6 @@ var MailTriage = common.Shortcut{
|
||||
var messages []map[string]interface{}
|
||||
var hasMore bool
|
||||
var nextPageToken string
|
||||
var notice string
|
||||
|
||||
useSearch, err := resolveTriagePath(parsed, query, filter)
|
||||
if err != nil {
|
||||
@@ -190,9 +189,6 @@ var MailTriage = common.Shortcut{
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if notice == "" {
|
||||
notice, _ = searchData["notice"].(string)
|
||||
}
|
||||
pageMessages := buildTriageMessagesFromSearchItems(searchData["items"])
|
||||
messages = append(messages, pageMessages...)
|
||||
pageHasMore, _ := searchData["has_more"].(bool)
|
||||
@@ -286,14 +282,8 @@ var MailTriage = common.Shortcut{
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
output.PrintJson(runtime.IO().Out, outData)
|
||||
default: // "table"
|
||||
if notice != "" {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "notice: %s\n", notice)
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
fmt.Fprintln(runtime.IO().ErrOut, "No messages found.")
|
||||
return nil
|
||||
@@ -770,7 +760,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["folder_id"] = folderIDFromFilter
|
||||
}
|
||||
} else {
|
||||
params["folder_id"] = folderIDFromFilter
|
||||
resolved, err := resolveFolderID(runtime, mailboxID, folderIDFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["folder_id"] = resolved
|
||||
}
|
||||
}
|
||||
} else if folderFromFilter != "" {
|
||||
if dryRun {
|
||||
@@ -780,7 +776,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["folder_id"] = folderFromFilter
|
||||
}
|
||||
} else {
|
||||
params["folder_id"] = folderFromFilter
|
||||
resolved, err := resolveFolderName(runtime, mailboxID, folderFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["folder_id"] = resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,7 +801,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["label_id"] = labelIDFromFilter
|
||||
}
|
||||
} else {
|
||||
params["label_id"] = labelIDFromFilter
|
||||
resolved, err := resolveLabelID(runtime, mailboxID, labelIDFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["label_id"] = resolved
|
||||
}
|
||||
}
|
||||
} else if labelFromFilter != "" {
|
||||
if dryRun {
|
||||
@@ -809,7 +817,13 @@ func buildListParams(runtime *common.RuntimeContext, mailboxID string, f triageF
|
||||
params["label_id"] = labelFromFilter
|
||||
}
|
||||
} else {
|
||||
params["label_id"] = labelFromFilter
|
||||
resolved, err := resolveLabelName(runtime, mailboxID, labelFromFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resolved != "" {
|
||||
params["label_id"] = resolved
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/auth"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -975,11 +974,7 @@ func TestBuildListParamsDryRunOnlyUnread(t *testing.T) {
|
||||
func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Folder: "sent"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 20, "", true)
|
||||
got, err := buildListParams(rt, "me", f, 20, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -988,30 +983,10 @@ func TestBuildListParamsDryRunFolderAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunCustomFolderPreservesInput(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Folder: "team-folder"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 20, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["folder_id"] != "team-folder" {
|
||||
t.Fatalf("expected dry-run folder_id=team-folder, got %v", got["folder_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Label: "flagged"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 10, "", true)
|
||||
got, err := buildListParams(rt, "me", f, 10, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1020,25 +995,6 @@ func TestBuildListParamsDryRunLabelAlias(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildListParamsDryRunCustomLabelPreservesInput(t *testing.T) {
|
||||
rt := runtimeForMailTriageTest(t, nil)
|
||||
f := triageFilter{Label: "custom-label"}
|
||||
resolved, err := resolveListFilter(rt, "me", f, true)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveListFilter: %v", err)
|
||||
}
|
||||
got, err := buildListParams(rt, "me", resolved, 10, "", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := got["folder_id"]; ok {
|
||||
t.Fatalf("folder_id should not be set when label is specified, got %v", got["folder_id"])
|
||||
}
|
||||
if got["label_id"] != "custom-label" {
|
||||
t.Fatalf("expected dry-run label_id=custom-label, got %v", got["label_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- buildSearchParams additional coverage ---
|
||||
|
||||
func TestBuildSearchParamsAllFilterFields(t *testing.T) {
|
||||
@@ -1522,16 +1478,14 @@ func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// --- mailbox_id preservation tests ---
|
||||
|
||||
// TestMailTriageStructuredOutputPreservesMailboxID verifies mailbox and notice metadata.
|
||||
func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mailbox string
|
||||
format string
|
||||
args []string
|
||||
register func(*httpmock.Registry, string)
|
||||
wantCount int
|
||||
wantNotice string
|
||||
name string
|
||||
mailbox string
|
||||
format string
|
||||
args []string
|
||||
register func(*httpmock.Registry, string)
|
||||
wantCount int
|
||||
}{
|
||||
{
|
||||
name: "list json default mailbox",
|
||||
@@ -1568,10 +1522,9 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
register: func(reg *httpmock.Registry, mailbox string) {
|
||||
registerMailTriageSearchStub(reg, mailbox, []interface{}{
|
||||
mailTriageSearchItem("search_pub_001", "Shared search"),
|
||||
}, false, "", "The query is too long and has been truncated to the first 50 characters for search.")
|
||||
}, false, "")
|
||||
},
|
||||
wantCount: 1,
|
||||
wantNotice: "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
wantCount: 1,
|
||||
},
|
||||
{
|
||||
name: "empty list json keeps top-level mailbox",
|
||||
@@ -1606,9 +1559,6 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
if data["mailbox_id"] != tt.mailbox {
|
||||
t.Fatalf("top-level mailbox_id mismatch: got %v, want %q", data["mailbox_id"], tt.mailbox)
|
||||
}
|
||||
if tt.wantNotice != "" && data["notice"] != tt.wantNotice {
|
||||
t.Fatalf("notice mismatch: got %v, want %q", data["notice"], tt.wantNotice)
|
||||
}
|
||||
messages := mailTriageMessagesFromOutput(t, data)
|
||||
if len(messages) != tt.wantCount {
|
||||
t.Fatalf("message count mismatch: got %d, want %d", len(messages), tt.wantCount)
|
||||
@@ -1622,7 +1572,6 @@ func TestMailTriageStructuredOutputPreservesMailboxID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageMissingMessageMetadataStillGetsMailboxID verifies fallback rows keep mailbox IDs.
|
||||
func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
@@ -1655,7 +1604,6 @@ func TestMailTriageMissingMessageMetadataStillGetsMailboxID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageTableOutputPreservesMailboxContext verifies public mailbox table hints.
|
||||
func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1706,33 +1654,6 @@ func TestMailTriageTableOutputPreservesMailboxContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr verifies stderr notices.
|
||||
func TestMailTriageDefaultTableOutputPrintsSearchNoticeToStderr(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, stderr, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
registerMailTriageSearchStub(reg, "me", []interface{}{
|
||||
mailTriageSearchItem("msg_search_notice", "Search notice result"),
|
||||
}, false, "", notice)
|
||||
|
||||
if err := runMountedMailShortcut(t, MailTriage, []string{
|
||||
"+triage",
|
||||
"--query", strings.Repeat("q", 81),
|
||||
}, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if out := stdout.String(); !strings.Contains(out, "msg_search_notice") {
|
||||
t.Fatalf("stdout should contain table row, got:\n%s", out)
|
||||
}
|
||||
if errOut := stderr.String(); !strings.Contains(errOut, "notice: "+notice) {
|
||||
t.Fatalf("stderr should contain search notice, got:\n%s", errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// decodeMailTriageJSONOutput decodes structured triage output for assertions.
|
||||
func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }) map[string]interface{} {
|
||||
t.Helper()
|
||||
var data map[string]interface{}
|
||||
@@ -1742,7 +1663,6 @@ func decodeMailTriageJSONOutput(t *testing.T, stdout interface{ Bytes() []byte }
|
||||
return data
|
||||
}
|
||||
|
||||
// mailTriageMessagesFromOutput extracts triage messages as object maps.
|
||||
func mailTriageMessagesFromOutput(t *testing.T, data map[string]interface{}) []map[string]interface{} {
|
||||
t.Helper()
|
||||
rawMessages, ok := data["messages"].([]interface{})
|
||||
@@ -1795,8 +1715,7 @@ func registerMailTriageBatchStub(reg *httpmock.Registry, mailbox string, message
|
||||
})
|
||||
}
|
||||
|
||||
// registerMailTriageSearchStub registers a mailbox search response for triage tests.
|
||||
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string, notices ...string) {
|
||||
func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items []interface{}, hasMore bool, pageToken string) {
|
||||
data := map[string]interface{}{
|
||||
"items": items,
|
||||
"has_more": hasMore,
|
||||
@@ -1804,9 +1723,6 @@ func registerMailTriageSearchStub(reg *httpmock.Registry, mailbox string, items
|
||||
if pageToken != "" {
|
||||
data["page_token"] = pageToken
|
||||
}
|
||||
if len(notices) > 0 && notices[0] != "" {
|
||||
data["notice"] = notices[0]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: mailboxPath(mailbox, "search"),
|
||||
@@ -1835,137 +1751,3 @@ func mailTriageSearchItem(messageID, subject string) map[string]interface{} {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// registerMailTriageFoldersListStub registers a NON-reusable stub for the
|
||||
// mailbox folders list API. Because it is non-reusable, any second hit returns
|
||||
// "httpmock: no stub for GET .../folders" — which is exactly the assertion we
|
||||
// use to prove resolveListFilter runs once and buildListParams does NOT
|
||||
// re-resolve. folderID/folderName is the single custom folder the API reports.
|
||||
func registerMailTriageFoldersListStub(reg *httpmock.Registry, mailbox, folderID, folderName string) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: mailboxPath(mailbox, "folders"),
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": folderID,
|
||||
"name": folderName,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// registerMailTriageListPageStub registers one page of the messages list API,
|
||||
// disambiguated from sibling pages by a URL substring unique to that page
|
||||
// (e.g. "page_size=5" for page 1 vs "page_size=2" for page 2). The substring
|
||||
// must NOT depend on query-param ordering: map iteration makes param order
|
||||
// nondeterministic, so prefer a value-only token like "page_size=N" (the N
|
||||
// differs per page because pageSize = maxCount - fetched_so_far). Non-reusable
|
||||
// so reg.Verify catches under- or over-consumption.
|
||||
func registerMailTriageListPageStub(reg *httpmock.Registry, urlSubstring string, items []string, hasMore bool, pageToken string) {
|
||||
data := map[string]interface{}{
|
||||
"items": items,
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if pageToken != "" {
|
||||
data["page_token"] = pageToken
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: urlSubstring,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// TestMailTriageCustomFolderResolvesOnceAcrossListPages is the regression test
|
||||
// for the bug where buildListParams re-called resolveFolderID on every list
|
||||
// page, turning "resolve once" into "1 + page_count" folder-list API calls and
|
||||
// easily tripping rate limits.
|
||||
//
|
||||
// Setup: a custom folder filter that forces resolveListFilter to hit the
|
||||
// folders list API once (to map folder name "team-folder" to folder_id), then two
|
||||
// messages-list pages. The folders list stub is non-reusable, so if
|
||||
// buildListParams re-resolves, the second hit fails with "no stub". The
|
||||
// messages-list stubs are page-specific (disambiguated by page_size in the
|
||||
// URL), so both pages are served and Verify asserts each fired exactly once.
|
||||
func TestMailTriageCustomFolderResolvesOnceAcrossListPages(t *testing.T) {
|
||||
f, stdout, _, reg := mailShortcutTestFactory(t)
|
||||
defer reg.Verify(t)
|
||||
|
||||
// listMailboxFolders (called once by resolveListFilter) gates on the
|
||||
// mail:user_mailbox.folder:read scope, which the default test token does
|
||||
// not carry. Re-store the token with that scope appended so the folders
|
||||
// API call is actually exercised (and thus the non-reusable folders stub
|
||||
// is the load-bearing "exactly once" assertion).
|
||||
const folderScope = "mail:user_mailbox.folder:read"
|
||||
cfg := mailTestConfig()
|
||||
if stored := auth.GetStoredToken(cfg.AppID, cfg.UserOpenId); stored != nil {
|
||||
if !strings.Contains(stored.Scope, folderScope) {
|
||||
stored.Scope = stored.Scope + " " + folderScope
|
||||
if err := auth.SetStoredToken(stored); err != nil {
|
||||
t.Fatalf("re-store token with folder scope: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
mailbox = "me"
|
||||
folderName = "team-folder"
|
||||
folderID = "fld_custom_team"
|
||||
page2Token = "tok_page2"
|
||||
)
|
||||
// --max 5 with listPageMax=20 → pageSize = 5-0 = 5 on page 1, then 5-3 = 2
|
||||
// on page 2. The page_size query value disambiguates the two list stubs.
|
||||
page1IDs := []string{"msg_a", "msg_b", "msg_c"}
|
||||
page2IDs := []string{"msg_d", "msg_e"}
|
||||
|
||||
// Folders list: registered exactly once, non-reusable. Any second folder
|
||||
// lookup (the bug) fails the test with "no stub for GET .../folders".
|
||||
registerMailTriageFoldersListStub(reg, mailbox, folderID, folderName)
|
||||
// Messages list, page 1: 3 ids, has_more, hands off a page-2 token. The
|
||||
// page_size value (5 = maxCount - 0) is unique to page 1; page 2 uses 2.
|
||||
registerMailTriageListPageStub(reg, "page_size=5", page1IDs, true, page2Token)
|
||||
// Messages list, page 2: 2 ids, terminal.
|
||||
registerMailTriageListPageStub(reg, "page_size=2", page2IDs, false, "")
|
||||
// Batch metadata fetch for all 5 ids.
|
||||
registerMailTriageBatchStub(reg, mailbox, []map[string]interface{}{
|
||||
mailTriageBatchMessage("msg_a", "Subject A"),
|
||||
mailTriageBatchMessage("msg_b", "Subject B"),
|
||||
mailTriageBatchMessage("msg_c", "Subject C"),
|
||||
mailTriageBatchMessage("msg_d", "Subject D"),
|
||||
mailTriageBatchMessage("msg_e", "Subject E"),
|
||||
})
|
||||
|
||||
args := []string{
|
||||
"+triage",
|
||||
"--as", "user",
|
||||
"--mailbox", mailbox,
|
||||
"--filter", `{"folder":"` + folderName + `"}`,
|
||||
"--max", "5",
|
||||
"--format", "json",
|
||||
}
|
||||
if err := runMountedMailShortcut(t, MailTriage, args, f, stdout); err != nil {
|
||||
t.Fatalf("unexpected error running +triage (likely a second folders API call — the bug): %v", err)
|
||||
}
|
||||
|
||||
data := decodeMailTriageJSONOutput(t, stdout)
|
||||
messages := mailTriageMessagesFromOutput(t, data)
|
||||
if len(messages) != 5 {
|
||||
t.Fatalf("expected 5 messages across 2 pages, got %d (stdout=%s)", len(messages), stdout.String())
|
||||
}
|
||||
if got := data["has_more"]; got != false {
|
||||
t.Fatalf("expected has_more=false after exhausting pages, got %v", got)
|
||||
}
|
||||
// All registered stubs (1 folders + 2 list pages + 1 batch_get) are
|
||||
// non-reusable; reg.Verify (deferred above) asserts each was matched
|
||||
// exactly once. Combined with the non-reusable folders stub, this is the
|
||||
// proof that the folders list API was called exactly once across both
|
||||
// pages — the core invariant the fix restores.
|
||||
}
|
||||
|
||||
@@ -308,9 +308,6 @@ var MinutesSearch = common.Shortcut{
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
|
||||
if len(rows) == 0 {
|
||||
|
||||
@@ -609,8 +609,6 @@ func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
|
||||
func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
@@ -619,7 +617,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
@@ -644,9 +641,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Notice string `json:"notice"`
|
||||
} `json:"data"`
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
} `json:"meta"`
|
||||
@@ -657,9 +651,6 @@ func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
|
||||
if envelope.Meta.Count != 1 {
|
||||
t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
|
||||
}
|
||||
if envelope.Data.Notice != notice {
|
||||
t.Fatalf("data.notice = %q, want %q", envelope.Data.Notice, notice)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.
|
||||
|
||||
@@ -1,385 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,593 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
@@ -1,391 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,448 +0,0 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
@@ -1,712 +0,0 @@
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
// 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
|
||||
},
|
||||
}
|
||||
@@ -1,747 +0,0 @@
|
||||
// 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,9 +18,5 @@ func Shortcuts() []common.Shortcut {
|
||||
OKRUpdateProgressRecord,
|
||||
OKRDeleteProgressRecord,
|
||||
OKRUploadImage,
|
||||
OKRBatchCreate,
|
||||
OKRReorder,
|
||||
OKRWeight,
|
||||
OKRIndicatorUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,5 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesCreate,
|
||||
SlidesMediaUpload,
|
||||
SlidesReplaceSlide,
|
||||
SlidesScreenshot,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,537 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/util"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const defaultSlidesScreenshotDir = ".lark-slides/screenshots"
|
||||
|
||||
var unsafeScreenshotFileCharRegex = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
|
||||
|
||||
// SlidesScreenshot fetches server-rendered slide screenshots and writes them to
|
||||
// local files. The raw API returns Base64 image payloads; this shortcut keeps
|
||||
// those payloads out of stdout so agents only see small file metadata.
|
||||
var SlidesScreenshot = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+screenshot",
|
||||
Description: "Save slide screenshots to local files without printing Base64 image data",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:screenshot"},
|
||||
// wiki:node:read is required only when --presentation is a wiki URL.
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides; list mode only"},
|
||||
{Name: "slide-id", Type: "string_array", Desc: "slide page identifier (repeat for multiple slides)"},
|
||||
{Name: "slide-number", Type: "int_array", Desc: "slide page number (repeat for multiple slides)"},
|
||||
{Name: "content", Desc: "slide XML content to render directly instead of fetching existing slides", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "output-dir", Default: defaultSlidesScreenshotDir, Desc: "relative directory for saved screenshots"},
|
||||
{Name: "output-name", Desc: "file name stem for --content render output"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
renderMode := runtime.Changed("content")
|
||||
if renderMode {
|
||||
if strings.TrimSpace(runtime.Str("content")) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
|
||||
}
|
||||
} else {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := normalizeSlideNumbers(runtime.IntArray("slide-number")); err != nil {
|
||||
return err
|
||||
}
|
||||
if !hasSlideScreenshotSelector(runtime) {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
}
|
||||
if _, err := validateScreenshotOutputDir(runtime, runtime.Str("output-dir")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
if runtime.Changed("content") {
|
||||
return dryRunRenderScreenshot(runtime)
|
||||
}
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--slide-id or --slide-number is required")
|
||||
}
|
||||
|
||||
presentationID := ref.Token
|
||||
dry := common.NewDryRunAPI()
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki → fetch slide screenshot(s)").
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc(fmt.Sprintf("Fetch %d slide screenshot(s) and save files under %s", len(slideIDs)+len(slideNumbers), runtime.Str("output-dir")))
|
||||
}
|
||||
body := map[string]interface{}{}
|
||||
if len(slideIDs) > 0 {
|
||||
body["slide_ids"] = slideIDs
|
||||
}
|
||||
if len(slideNumbers) > 0 {
|
||||
body["slide_numbers"] = slideNumbers
|
||||
}
|
||||
dry.POST(fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)).
|
||||
Body(body)
|
||||
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local files during execution")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("content") {
|
||||
return executeRenderScreenshot(runtime)
|
||||
}
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
slideIDs := normalizeSlideIDs(runtime.StrArray("slide-id"))
|
||||
slideNumbers, err := normalizeSlideNumbers(runtime.IntArray("slide-number"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(slideIDs) == 0 && len(slideNumbers) == 0 {
|
||||
return slidesScreenshotFlagErrorf("--slide-id or --slide-number is required")
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
url := fmt.Sprintf(
|
||||
"/open-apis/slides_ai/v1/xml_presentations/%s/slide_images",
|
||||
validate.EncodePathSegment(presentationID),
|
||||
)
|
||||
query := larkcore.QueryParams{}
|
||||
body := map[string]interface{}{}
|
||||
if len(slideIDs) > 0 {
|
||||
body["slide_ids"] = slideIDs
|
||||
}
|
||||
if len(slideNumbers) > 0 {
|
||||
body["slide_numbers"] = slideNumbers
|
||||
}
|
||||
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", url, query, body)
|
||||
if err != nil {
|
||||
return enrichSlidesScreenshotSelectorError(err, slideNumbers)
|
||||
}
|
||||
|
||||
saved, err := saveSlideScreenshots(runtime, data, safeOutputDir, presentationID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"xml_presentation_id": presentationID,
|
||||
"output_dir": outputDir,
|
||||
"screenshots": saved,
|
||||
}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func dryRunRenderScreenshot(runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
content := runtime.Str("content")
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return common.NewDryRunAPI().Set("error", "--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
return common.NewDryRunAPI().Set("error", "--presentation cannot be used with --content")
|
||||
}
|
||||
dry := common.NewDryRunAPI().Desc("Render slide XML content to a screenshot file")
|
||||
dry.POST("/open-apis/slides_ai/v1/slide_image/render").
|
||||
Body(map[string]interface{}{
|
||||
"content": fmt.Sprintf("<xml omitted; length=%d>", len(content)),
|
||||
})
|
||||
return dry.Set("output_dir", runtime.Str("output-dir")).Set("base64_output", "suppressed; decoded to local file during execution")
|
||||
}
|
||||
|
||||
func executeRenderScreenshot(runtime *common.RuntimeContext) error {
|
||||
content := runtime.Str("content")
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be empty")
|
||||
}
|
||||
if len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0 {
|
||||
return slidesScreenshotFlagErrorf("--content cannot be used with --slide-id or --slide-number")
|
||||
}
|
||||
if runtime.Changed("presentation") {
|
||||
return slidesScreenshotFlagErrorf("--presentation cannot be used with --content")
|
||||
}
|
||||
outputDir := runtime.Str("output-dir")
|
||||
safeOutputDir, err := ensureScreenshotOutputDir(runtime, outputDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := doSlidesScreenshotAPIJSONWithLogID(runtime, "POST", "/open-apis/slides_ai/v1/slide_image/render", larkcore.QueryParams{}, map[string]interface{}{
|
||||
"content": content,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
saved, err := saveRenderedSlideScreenshot(runtime, data, safeOutputDir, runtime.Str("output-name"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{
|
||||
"output_dir": outputDir,
|
||||
"screenshots": saved,
|
||||
}, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeSlideIDs(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]struct{}{}
|
||||
for _, v := range values {
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[s]; ok {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeSlideNumbers(values []int) ([]int, error) {
|
||||
out := make([]int, 0, len(values))
|
||||
seen := map[int]struct{}{}
|
||||
for _, n := range values {
|
||||
if n < 1 {
|
||||
return nil, slidesScreenshotFlagErrorf("--slide-number must be a positive integer")
|
||||
}
|
||||
if _, ok := seen[n]; ok {
|
||||
continue
|
||||
}
|
||||
seen[n] = struct{}{}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func hasSlideScreenshotSelector(runtime *common.RuntimeContext) bool {
|
||||
return len(normalizeSlideIDs(runtime.StrArray("slide-id"))) > 0 || len(runtime.IntArray("slide-number")) > 0
|
||||
}
|
||||
|
||||
func slidesScreenshotFlagErrorf(format string, args ...interface{}) error {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...)
|
||||
}
|
||||
|
||||
func validateScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
|
||||
if _, err := runtime.ResolveSavePath(filepath.Join(outputDir, "probe.png")); err != nil {
|
||||
return "", slidesScreenshotFlagErrorf("--output-dir invalid: %v", err)
|
||||
}
|
||||
return outputDir, nil
|
||||
}
|
||||
|
||||
func ensureScreenshotOutputDir(runtime *common.RuntimeContext, outputDir string) (string, error) {
|
||||
return validateScreenshotOutputDir(runtime, outputDir)
|
||||
}
|
||||
|
||||
func saveSlideScreenshots(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, presentationID string) ([]map[string]interface{}, error) {
|
||||
items := common.GetSlice(data, "slide_images")
|
||||
if len(items) == 0 {
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned no slide_images")
|
||||
}
|
||||
saved := make([]map[string]interface{}, 0, len(items))
|
||||
for i, item := range items {
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]", i)
|
||||
}
|
||||
item, err := saveSlideScreenshotImage(runtime, m, outputDir, slideScreenshotListFileBase(presentationID, m, i), "")
|
||||
if err != nil {
|
||||
if isSlidesScreenshotPassthroughError(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides screenshot returned invalid slide_images[%d]: %v", i, err)
|
||||
}
|
||||
saved = append(saved, item)
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
func saveRenderedSlideScreenshot(runtime *common.RuntimeContext, data map[string]interface{}, outputDir string, outputName string) ([]map[string]interface{}, error) {
|
||||
item := common.GetMap(data, "slide_image")
|
||||
if item == nil {
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned no slide_image")
|
||||
}
|
||||
saved, err := saveSlideScreenshotImage(runtime, item, outputDir, outputName, "rendered-slide")
|
||||
if err != nil {
|
||||
if isSlidesScreenshotPassthroughError(err) {
|
||||
return nil, err
|
||||
}
|
||||
return nil, slidesScreenshotAPIDataError(data, "slides render screenshot returned invalid slide_image: %v", err)
|
||||
}
|
||||
return []map[string]interface{}{saved}, nil
|
||||
}
|
||||
|
||||
func saveSlideScreenshotImage(runtime *common.RuntimeContext, item map[string]interface{}, outputDir string, outputName string, fallbackName string) (map[string]interface{}, error) {
|
||||
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
|
||||
ext, label, err := slideScreenshotFormat(item)
|
||||
if err != nil {
|
||||
return nil, slidesScreenshotImageDataError(slideID, "%s", err)
|
||||
}
|
||||
encoded := strings.TrimSpace(common.GetString(item, "data"))
|
||||
if encoded == "" {
|
||||
return nil, slidesScreenshotImageDataError(slideID, "empty image data")
|
||||
}
|
||||
imageBytes, err := base64.StdEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return nil, slidesScreenshotImageDataCauseError(slideID, err, "decode screenshot: %s", err)
|
||||
}
|
||||
fileBase := strings.TrimSpace(outputName)
|
||||
if fileBase == "" {
|
||||
fileBase = slideID
|
||||
}
|
||||
if fileBase == "" {
|
||||
fileBase = fallbackName
|
||||
}
|
||||
path, err := writeUniqueScreenshotFile(runtime, outputDir, fileBase, ext, imageBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"slide_id": slideID,
|
||||
"slide_number": slideScreenshotInt(item, "slide_number"),
|
||||
"format": label,
|
||||
"path": path,
|
||||
"size": len(imageBytes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func slideScreenshotListFileBase(presentationID string, item map[string]interface{}, index int) string {
|
||||
presentationID = strings.TrimSpace(presentationID)
|
||||
slideID := strings.TrimSpace(common.GetString(item, "slide_id"))
|
||||
slideNumber := slideScreenshotInt(item, "slide_number")
|
||||
if presentationID != "" {
|
||||
switch {
|
||||
case slideNumber > 0 && slideID != "":
|
||||
return fmt.Sprintf("%s_p%03d_%s", presentationID, slideNumber, slideID)
|
||||
case slideNumber > 0:
|
||||
return fmt.Sprintf("%s_p%03d", presentationID, slideNumber)
|
||||
case slideID != "":
|
||||
return fmt.Sprintf("%s_%s", presentationID, slideID)
|
||||
}
|
||||
}
|
||||
if slideID != "" {
|
||||
return slideID
|
||||
}
|
||||
if slideNumber := slideScreenshotInt(item, "slide_number"); slideNumber > 0 {
|
||||
return fmt.Sprintf("slide-%d", slideNumber)
|
||||
}
|
||||
return fmt.Sprintf("slide-%d", index+1)
|
||||
}
|
||||
|
||||
func slideScreenshotFormat(item map[string]interface{}) (string, string, error) {
|
||||
format := slideScreenshotInt(item, "format")
|
||||
switch format {
|
||||
case 1:
|
||||
return "png", "png", nil
|
||||
case 2:
|
||||
return "jpg", "jpeg", nil
|
||||
default:
|
||||
return "", "", errs.NewAPIError(errs.SubtypeInvalidResponse, "unsupported screenshot format %d", format)
|
||||
}
|
||||
}
|
||||
|
||||
func slidesScreenshotImageDataError(slideID string, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if slideID != "" {
|
||||
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg)
|
||||
}
|
||||
|
||||
func slidesScreenshotImageDataCauseError(slideID string, cause error, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
if slideID != "" {
|
||||
msg = fmt.Sprintf("%s for slide %s", msg, slideID)
|
||||
}
|
||||
return errs.NewAPIError(errs.SubtypeInvalidResponse, "%s", msg).WithCause(cause)
|
||||
}
|
||||
|
||||
func slideScreenshotInt(item map[string]interface{}, key string) int {
|
||||
n, ok := util.ToFloat64(item[key])
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return int(n)
|
||||
}
|
||||
|
||||
func doSlidesScreenshotAPIJSONWithLogID(runtime *common.RuntimeContext, method string, apiPath string, query larkcore.QueryParams, body interface{}) (map[string]interface{}, error) {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: method,
|
||||
ApiPath: apiPath,
|
||||
QueryParams: query,
|
||||
}
|
||||
if body != nil {
|
||||
req.Body = body
|
||||
}
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return nil, errs.WrapInternal(err)
|
||||
}
|
||||
data, err := runtime.ClassifyAPIResponse(resp)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
if data == nil {
|
||||
data = map[string]interface{}{}
|
||||
}
|
||||
if logID := strings.TrimSpace(resp.Header.Get("x-tt-logid")); logID != "" {
|
||||
data["log_id"] = logID
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func enrichSlidesScreenshotSelectorError(err error, slideNumbers []int) error {
|
||||
if len(slideNumbers) == 0 {
|
||||
return err
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
if p.Hint == "" {
|
||||
p.Hint = "slide_numbers was rejected by the server; verify the page number exists in this presentation, or retry with --slide-id."
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func slidesScreenshotAPIDataError(data map[string]interface{}, format string, args ...interface{}) error {
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
err := errs.NewAPIError(errs.SubtypeInvalidResponse, "%s; raw_data=%v", msg, summarizeScreenshotAPIData(data))
|
||||
if logID := strings.TrimSpace(common.GetString(data, "log_id")); logID != "" {
|
||||
err = err.WithLogID(logID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func isSlidesScreenshotPassthroughError(err error) bool {
|
||||
_, ok := errs.ProblemOf(err)
|
||||
return ok
|
||||
}
|
||||
|
||||
func summarizeScreenshotAPIData(v interface{}) interface{} {
|
||||
switch x := v.(type) {
|
||||
case map[string]interface{}:
|
||||
out := make(map[string]interface{}, len(x))
|
||||
for k, val := range x {
|
||||
out[k] = summarizeScreenshotAPIData(val)
|
||||
}
|
||||
return out
|
||||
case []interface{}:
|
||||
out := make([]interface{}, 0, len(x))
|
||||
for i, val := range x {
|
||||
if i >= 20 {
|
||||
out = append(out, fmt.Sprintf("<omitted %d more items>", len(x)-i))
|
||||
break
|
||||
}
|
||||
out = append(out, summarizeScreenshotAPIData(val))
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
if len(x) > 512 {
|
||||
return fmt.Sprintf("<omitted string length=%d prefix=%q>", len(x), x[:64])
|
||||
}
|
||||
return x
|
||||
default:
|
||||
return x
|
||||
}
|
||||
}
|
||||
|
||||
func safeScreenshotFileBase(base string) string {
|
||||
name := unsafeScreenshotFileCharRegex.ReplaceAllString(base, "_")
|
||||
name = strings.Trim(name, "._-")
|
||||
if name == "" {
|
||||
name = "slide"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func writeUniqueScreenshotFile(runtime *common.RuntimeContext, outputDir string, fileBase string, ext string, imageBytes []byte) (string, error) {
|
||||
base := safeScreenshotFileBase(fileBase)
|
||||
for i := 0; i < 1000; i++ {
|
||||
candidateBase := base
|
||||
if i > 0 {
|
||||
candidateBase = fmt.Sprintf("%s_%d", base, i+1)
|
||||
}
|
||||
path := filepath.Join(outputDir, candidateBase+"."+ext)
|
||||
if _, err := runtime.FileIO().Stat(path); err == nil {
|
||||
continue
|
||||
} else if !isScreenshotFileNotExist(err) {
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: %v", path, err).WithCause(err)
|
||||
}
|
||||
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(imageBytes)); err != nil {
|
||||
return "", common.WrapSaveErrorTyped(err)
|
||||
}
|
||||
resolvedPath, err := runtime.ResolveSavePath(path)
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "resolve saved screenshot path %s: %v", path, err).WithCause(err)
|
||||
}
|
||||
return resolvedPath, nil
|
||||
}
|
||||
path := filepath.Join(outputDir, base+"."+ext)
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "write screenshot %s: too many duplicate file names", path)
|
||||
}
|
||||
|
||||
func isScreenshotFileNotExist(err error) bool {
|
||||
return os.IsNotExist(err)
|
||||
}
|
||||
@@ -1,506 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestSlidesScreenshotDeclaredScopes(t *testing.T) {
|
||||
got := SlidesScreenshot.DeclaredScopesForIdentity("user")
|
||||
want := []string{"slides:presentation:screenshot", "wiki:node:read"}
|
||||
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotWritesFilesAndSuppressesBase64(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
imageBytes := []byte("png-bytes")
|
||||
jpegBytes := []byte("jpeg-bytes")
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_id": "slide_1",
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString(imageBytes),
|
||||
},
|
||||
{
|
||||
"slide_id": "slide_2",
|
||||
"slide_number": 2,
|
||||
"format": 2,
|
||||
"data": base64.StdEncoding.EncodeToString(jpegBytes),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--output-dir", "shots",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "shots", "pres_abc_slide_1.png")
|
||||
gotBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read screenshot: %v", err)
|
||||
}
|
||||
if string(gotBytes) != string(imageBytes) {
|
||||
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
|
||||
}
|
||||
jpegPath := filepath.Join(dir, "shots", "pres_abc_p002_slide_2.jpg")
|
||||
gotJPEGBytes, err := os.ReadFile(jpegPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read jpeg screenshot: %v", err)
|
||||
}
|
||||
if string(gotJPEGBytes) != string(jpegBytes) {
|
||||
t.Fatalf("written jpeg bytes = %q, want %q", gotJPEGBytes, jpegBytes)
|
||||
}
|
||||
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
|
||||
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
if data["xml_presentation_id"] != "pres_abc" {
|
||||
t.Fatalf("xml_presentation_id = %v", data["xml_presentation_id"])
|
||||
}
|
||||
items, ok := data["screenshots"].([]interface{})
|
||||
if !ok || len(items) != 2 {
|
||||
t.Fatalf("screenshots = %#v, want two items", data["screenshots"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if item["slide_id"] != "slide_1" {
|
||||
t.Fatalf("slide_id = %v, want slide_1", item["slide_id"])
|
||||
}
|
||||
gotPath := item["path"].(string)
|
||||
if !filepath.IsAbs(gotPath) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath, filepath.Join("shots", "pres_abc_slide_1.png")) {
|
||||
t.Fatalf("path = %v, want shots/pres_abc_slide_1.png suffix", item["path"])
|
||||
}
|
||||
item2, _ := items[1].(map[string]interface{})
|
||||
if item2["format"] != "jpeg" {
|
||||
t.Fatalf("format = %v, want jpeg", item2["format"])
|
||||
}
|
||||
gotPath2 := item2["path"].(string)
|
||||
if !filepath.IsAbs(gotPath2) {
|
||||
t.Fatalf("path = %v, want absolute path", gotPath2)
|
||||
}
|
||||
if !strings.HasSuffix(gotPath2, filepath.Join("shots", "pres_abc_p002_slide_2.jpg")) {
|
||||
t.Fatalf("path = %v, want shots/pres_abc_p002_slide_2.jpg suffix", item2["path"])
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideIDs []string `json:"slide_ids"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideIDs) != 1 || body.SlideIDs[0] != "slide_1" {
|
||||
t.Fatalf("slide_ids = %#v, want [slide_1]", body.SlideIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListBySlideNumber(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_number": 2,
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString([]byte("png-bytes")),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "2",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body struct {
|
||||
SlideNumbers []int `json:"slide_numbers"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if len(body.SlideNumbers) != 1 || body.SlideNumbers[0] != 2 {
|
||||
t.Fatalf("slide_numbers = %#v, want [2]", body.SlideNumbers)
|
||||
}
|
||||
path := filepath.Join(dir, defaultSlidesScreenshotDir, "pres_abc_p002.png")
|
||||
if _, err := os.ReadFile(path); err != nil {
|
||||
t.Fatalf("read screenshot without slide_id: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotAvoidsOverwritingExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
outputDir := filepath.Join(dir, "shots")
|
||||
if err := os.MkdirAll(outputDir, 0o755); err != nil {
|
||||
t.Fatalf("create output dir: %v", err)
|
||||
}
|
||||
existingPath := filepath.Join(outputDir, "pres_abc_p002.png")
|
||||
if err := os.WriteFile(existingPath, []byte("existing"), 0o644); err != nil {
|
||||
t.Fatalf("write existing screenshot: %v", err)
|
||||
}
|
||||
|
||||
imageBytes := []byte("new-png")
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_images": []map[string]interface{}{
|
||||
{
|
||||
"slide_number": 2,
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString(imageBytes),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "2",
|
||||
"--output-dir", "shots",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
gotExisting, err := os.ReadFile(existingPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read existing screenshot: %v", err)
|
||||
}
|
||||
if string(gotExisting) != "existing" {
|
||||
t.Fatalf("existing screenshot = %q, want unchanged", gotExisting)
|
||||
}
|
||||
newPath := filepath.Join(outputDir, "pres_abc_p002_2.png")
|
||||
gotNew, err := os.ReadFile(newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read deduplicated screenshot: %v", err)
|
||||
}
|
||||
if string(gotNew) != string(imageBytes) {
|
||||
t.Fatalf("deduplicated screenshot = %q, want %q", gotNew, imageBytes)
|
||||
}
|
||||
data := decodeShortcutData(t, stdout)
|
||||
items, ok := data["screenshots"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("screenshots = %#v, want one item", data["screenshots"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "pres_abc_p002_2.png")) {
|
||||
t.Fatalf("path = %v, want shots/pres_abc_p002_2.png suffix", item["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotListRequiresSelector(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--slide-id or --slide-number is required") {
|
||||
t.Fatalf("error = %v, want missing selector error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderContentWritesFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
content := `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`
|
||||
if err := os.WriteFile(filepath.Join(dir, "slide.xml"), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write input xml: %v", err)
|
||||
}
|
||||
imageBytes := []byte("rendered-png")
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/slide_image/render",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_image": map[string]interface{}{
|
||||
"slide_id": "render_slide",
|
||||
"slide_number": 1,
|
||||
"format": 1,
|
||||
"data": base64.StdEncoding.EncodeToString(imageBytes),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", "@slide.xml",
|
||||
"--output-dir", "shots",
|
||||
"--output-name", "preview",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "shots", "preview.png")
|
||||
gotBytes, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read rendered screenshot: %v", err)
|
||||
}
|
||||
if string(gotBytes) != string(imageBytes) {
|
||||
t.Fatalf("written bytes = %q, want %q", gotBytes, imageBytes)
|
||||
}
|
||||
if strings.Contains(stdout.String(), base64.StdEncoding.EncodeToString(imageBytes)) {
|
||||
t.Fatalf("stdout leaked base64 image data: %s", stdout.String())
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode request body: %v", err)
|
||||
}
|
||||
if body.Content != content {
|
||||
t.Fatalf("content = %q, want input XML", body.Content)
|
||||
}
|
||||
|
||||
data := decodeShortcutData(t, stdout)
|
||||
items, ok := data["screenshots"].([]interface{})
|
||||
if !ok || len(items) != 1 {
|
||||
t.Fatalf("screenshots = %#v, want one item", data["screenshots"])
|
||||
}
|
||||
item, _ := items[0].(map[string]interface{})
|
||||
if !strings.HasSuffix(item["path"].(string), filepath.Join("shots", "preview.png")) {
|
||||
t.Fatalf("path = %v, want shots/preview.png suffix", item["path"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsSlideSelectors(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--slide-id", "slide_1",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--content cannot be used with --slide-id or --slide-number") {
|
||||
t.Fatalf("error = %v, want content/slide selector conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRenderRejectsListOnlyFlags(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--presentation", "pres_abc",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--presentation cannot be used with --content") {
|
||||
t.Fatalf("error = %v, want presentation/content conflict", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotDryRunSelectsListOrRenderAPI(t *testing.T) {
|
||||
t.Run("list", func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "2",
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/xml_presentations/pres_abc/slide_images") {
|
||||
t.Fatalf("dry-run missing list endpoint: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "slide_numbers") {
|
||||
t.Fatalf("dry-run missing slide_numbers body: %s", out)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("render", func(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--content", `<slide xmlns="http://www.larkoffice.com/sml/2.0"><data></data></slide>`,
|
||||
"--dry-run",
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, "/slide_image/render") {
|
||||
t.Fatalf("dry-run missing render endpoint: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, "base64_output") {
|
||||
t.Fatalf("dry-run missing base64 suppression note: %s", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotRejectsBadOutputDir(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "slide_1",
|
||||
"--output-dir", "../outside",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsafe output dir")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--output-dir invalid") {
|
||||
t.Fatalf("error = %v, want output-dir validation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotNoImagesErrorIncludesRawDataAndLogID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
"X-Tt-Logid": {"log-123"},
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"unexpected": "shape",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-id", "pJJ",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed problem", err)
|
||||
}
|
||||
if p.LogID != "log-123" {
|
||||
t.Fatalf("log_id = %v, want log-123", p.LogID)
|
||||
}
|
||||
if !strings.Contains(p.Message, "unexpected:shape") {
|
||||
t.Fatalf("message = %q, want raw_data summary", p.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesScreenshotSlideNumberAPIErrorAddsHint(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
withSlidesTestWorkingDir(t, dir)
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide_images",
|
||||
Headers: map[string][]string{
|
||||
"Content-Type": {"application/json"},
|
||||
"X-Tt-Logid": {"log-slide-number"},
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 99992402,
|
||||
"msg": "field validation failed",
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesScreenshot, []string{
|
||||
"+screenshot",
|
||||
"--presentation", "pres_abc",
|
||||
"--slide-number", "25",
|
||||
"--as", "user",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error type = %T, want typed problem", err)
|
||||
}
|
||||
if p.LogID != "log-slide-number" {
|
||||
t.Fatalf("log_id = %v, want log-slide-number", p.LogID)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "--slide-id") {
|
||||
t.Fatalf("hint = %q, want --slide-id guidance", p.Hint)
|
||||
}
|
||||
}
|
||||
@@ -242,6 +242,7 @@ func Shortcuts() []common.Shortcut {
|
||||
GetMyTasks,
|
||||
GetRelatedTasks,
|
||||
SearchTask,
|
||||
SubscribeTaskEvent,
|
||||
UploadAttachmentTask,
|
||||
CreateTasklist,
|
||||
SearchTasklist,
|
||||
|
||||
@@ -73,16 +73,12 @@ var SearchTask = common.Shortcut{
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasks/search", nil, currentBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if notice == "" {
|
||||
notice, _ = data["notice"].(string)
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
@@ -119,9 +115,6 @@ var SearchTask = common.Shortcut{
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) {
|
||||
if len(enriched) == 0 {
|
||||
fmt.Fprintln(w, "No tasks found.")
|
||||
|
||||
@@ -153,7 +153,6 @@ func TestSearchTask_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchTask_Execute verifies task search output, enrichment, and notices.
|
||||
func TestSearchTask_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -172,7 +171,6 @@ func TestSearchTask_Execute(t *testing.T) {
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
@@ -193,7 +191,7 @@ func TestSearchTask_Execute(t *testing.T) {
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`, `"notice": "The query is too long and has been truncated to the first 50 characters for search."`},
|
||||
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`},
|
||||
},
|
||||
{
|
||||
name: "fallback to app link",
|
||||
|
||||
40
shortcuts/task/task_subscribe_event.go
Normal file
40
shortcuts/task/task_subscribe_event.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var SubscribeTaskEvent = common.Shortcut{
|
||||
Service: "task",
|
||||
Command: "+subscribe-event",
|
||||
Description: "subscribe to task events",
|
||||
Risk: "write",
|
||||
Scopes: []string{"task:task:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/task/v2/task_v2/task_subscription").
|
||||
Params(map[string]interface{}{"user_id_type": "open_id"})
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
params := map[string]interface{}{"user_id_type": "open_id"}
|
||||
if _, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/task_v2/task_subscription", params, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
outData := map[string]interface{}{"ok": true}
|
||||
runtime.OutFormat(outData, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "✅ Task event subscription created successfully!")
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
163
shortcuts/task/task_subscribe_event_test.go
Normal file
163
shortcuts/task/task_subscribe_event_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestSubscribeTaskEvent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
args []string
|
||||
register func(*httpmock.Registry)
|
||||
wantErr bool
|
||||
wantParts []string
|
||||
}{
|
||||
{
|
||||
name: "execute json (user identity)",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "user", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"ok": true`},
|
||||
},
|
||||
{
|
||||
name: "execute json (bot identity)",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"ok": true`},
|
||||
},
|
||||
{
|
||||
name: "execute api error",
|
||||
mode: "execute",
|
||||
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
|
||||
register: func(reg *httpmock.Registry) {
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Body: map[string]interface{}{
|
||||
"code": 401,
|
||||
"msg": "Unauthorized",
|
||||
"error": map[string]interface{}{
|
||||
"log_id": "test-log-id",
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
wantErr: true,
|
||||
wantParts: []string{"Unauthorized"},
|
||||
},
|
||||
{
|
||||
name: "dry run",
|
||||
mode: "dryrun",
|
||||
wantParts: []string{"POST /open-apis/task/v2/task_v2/task_subscription"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
switch tt.mode {
|
||||
case "execute":
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
if tt.register != nil {
|
||||
tt.register(reg)
|
||||
}
|
||||
|
||||
err := runMountedTaskShortcut(t, SubscribeTaskEvent, tt.args, f, stdout)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
out := err.Error()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("error missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("runMountedTaskShortcut() error = %v", err)
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
|
||||
t.Fatalf("output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
case "dryrun":
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, taskTestConfig(t), "user")
|
||||
out := SubscribeTaskEvent.DryRun(nil, runtime).Format()
|
||||
for _, want := range tt.wantParts {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Fatalf("dry run output missing %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscribeTaskEvent_MalformedResponse covers the parse-response arm: a 200
|
||||
// with an unparseable body surfaces a typed internal invalid_response error
|
||||
// (exit 5).
|
||||
func TestSubscribeTaskEvent_MalformedResponse(t *testing.T) {
|
||||
f, stdout, _, reg := taskShortcutTestFactory(t)
|
||||
warmTenantToken(t, f, reg)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/task/v2/task_v2/task_subscription",
|
||||
Status: 200,
|
||||
RawBody: []byte("{not-json"),
|
||||
})
|
||||
|
||||
args := []string{"+subscribe-event", "--as", "bot", "--format", "json"}
|
||||
err := runMountedTaskShortcut(t, SubscribeTaskEvent, args, f, stdout)
|
||||
|
||||
var ie *errs.InternalError
|
||||
if !errors.As(err, &ie) {
|
||||
t.Fatalf("err = %T, want *errs.InternalError; err = %v", err, err)
|
||||
}
|
||||
if ie.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Errorf("subtype = %q, want %q", ie.Subtype, errs.SubtypeInvalidResponse)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Errorf("exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
@@ -70,16 +70,12 @@ var SearchTasklist = common.Shortcut{
|
||||
var rawItems []interface{}
|
||||
var lastPageToken string
|
||||
var lastHasMore bool
|
||||
var notice string
|
||||
currentBody := body
|
||||
for page := 0; page < pageLimit; page++ {
|
||||
data, err := callTaskAPITyped(runtime, http.MethodPost, "/open-apis/task/v2/tasklists/search", nil, currentBody)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if notice == "" {
|
||||
notice, _ = data["notice"].(string)
|
||||
}
|
||||
items, _ := data["items"].([]interface{})
|
||||
rawItems = append(rawItems, items...)
|
||||
lastHasMore, _ = data["has_more"].(bool)
|
||||
@@ -122,9 +118,6 @@ var SearchTasklist = common.Shortcut{
|
||||
"page_token": lastPageToken,
|
||||
"has_more": lastHasMore,
|
||||
}
|
||||
if notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(tasklists)}, func(w io.Writer) {
|
||||
if len(tasklists) == 0 {
|
||||
fmt.Fprintln(w, "No tasklists found.")
|
||||
|
||||
@@ -126,7 +126,6 @@ func TestSearchTasklist_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchTasklist_Execute verifies tasklist search output, enrichment, and notices.
|
||||
func TestSearchTasklist_Execute(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -145,7 +144,6 @@ func TestSearchTasklist_Execute(t *testing.T) {
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"notice": "The query is too long and has been truncated to the first 50 characters for search.",
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{map[string]interface{}{"id": "tl-123"}},
|
||||
@@ -164,7 +162,7 @@ func TestSearchTasklist_Execute(t *testing.T) {
|
||||
},
|
||||
})
|
||||
},
|
||||
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`, `"notice": "The query is too long and has been truncated to the first 50 characters for search."`},
|
||||
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`},
|
||||
},
|
||||
{
|
||||
name: "fallback on detail error",
|
||||
|
||||
@@ -236,9 +236,6 @@ var VCSearch = common.Shortcut{
|
||||
"has_more": data["has_more"],
|
||||
"page_token": data["page_token"],
|
||||
}
|
||||
if notice, _ := data["notice"].(string); notice != "" {
|
||||
outData["notice"] = notice
|
||||
}
|
||||
hasMore, _ := data["has_more"].(bool)
|
||||
runtime.OutFormat(outData, &output.Meta{Count: len(items)}, func(w io.Writer) {
|
||||
if len(items) == 0 {
|
||||
|
||||
@@ -5,7 +5,6 @@ package vc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -15,7 +14,6 @@ import (
|
||||
"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/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -255,7 +253,6 @@ func TestSearch_Validation_InvalidPageSize(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearch_DryRun verifies meeting search dry-run includes the API path.
|
||||
func TestSearch_DryRun(t *testing.T) {
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCSearch, []string{"+search", "--query", "test", "--dry-run", "--as", "user"}, f, stdout)
|
||||
@@ -267,43 +264,6 @@ func TestSearch_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearch_ExecutePassesThroughNotice verifies meeting search notice output.
|
||||
func TestSearch_ExecutePassesThroughNotice(t *testing.T) {
|
||||
const notice = "The query is too long and has been truncated to the first 50 characters for search."
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/vc/v1/meetings/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"notice": notice,
|
||||
"items": []interface{}{},
|
||||
"total": 0,
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := mountAndRun(t, VCSearch, []string{"+search", "--query", "incident", "--format", "json", "--as", "user"}, f, stdout); err != nil {
|
||||
t.Fatalf("VCSearch.Execute() error = %v", err)
|
||||
}
|
||||
reg.Verify(t)
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("json.Unmarshal(stdout) error = %v\nstdout=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if got, _ := data["notice"].(string); got != notice {
|
||||
t.Fatalf("data.notice = %q, want %q; data=%#v", got, notice, data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearch_InvalidTimeRange verifies invalid meeting search time input fails.
|
||||
func TestSearch_InvalidTimeRange(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
||||
err := mountAndRun(t, VCSearch, []string{"+search", "--start", "bad-time", "--as", "user"}, f, nil)
|
||||
|
||||
@@ -5,7 +5,6 @@ package whiteboard
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -22,54 +21,40 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// WhiteboardQueryAsImage exports a whiteboard preview image.
|
||||
WhiteboardQueryAsImage = "image"
|
||||
// WhiteboardQueryAsSvg exports a whiteboard as SVG.
|
||||
WhiteboardQueryAsSvg = "svg"
|
||||
// WhiteboardQueryAsCode exports Mermaid or PlantUML source extracted from the whiteboard.
|
||||
WhiteboardQueryAsCode = "code"
|
||||
// WhiteboardQueryAsRaw exports the raw whiteboard node payload.
|
||||
WhiteboardQueryAsRaw = "raw"
|
||||
WhiteboardQueryAsCode = "code"
|
||||
WhiteboardQueryAsRaw = "raw"
|
||||
)
|
||||
|
||||
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
|
||||
type SyntaxType int
|
||||
|
||||
const (
|
||||
// SyntaxTypePlantUML marks PlantUML code blocks.
|
||||
SyntaxTypePlantUML SyntaxType = 1
|
||||
// SyntaxTypeMermaid marks Mermaid code blocks.
|
||||
SyntaxTypeMermaid SyntaxType = 2
|
||||
SyntaxTypeMermaid SyntaxType = 2
|
||||
)
|
||||
|
||||
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
|
||||
var SyntaxTypeNameMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: "plantuml",
|
||||
SyntaxTypeMermaid: "mermaid",
|
||||
}
|
||||
|
||||
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
|
||||
var SyntaxTypeExtensionMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: ".puml",
|
||||
SyntaxTypeMermaid: ".mmd",
|
||||
}
|
||||
|
||||
// String returns the CLI-facing name for the syntax type.
|
||||
func (s SyntaxType) String() string {
|
||||
return SyntaxTypeNameMap[s]
|
||||
}
|
||||
|
||||
// ExtensionName returns the default file extension for the syntax type.
|
||||
func (s SyntaxType) ExtensionName() string {
|
||||
return SyntaxTypeExtensionMap[s]
|
||||
}
|
||||
|
||||
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
|
||||
func (s SyntaxType) IsValid() bool {
|
||||
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
|
||||
}
|
||||
|
||||
// WhiteboardQuery registers the `whiteboard +query` shortcut.
|
||||
var WhiteboardQuery = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+query",
|
||||
@@ -79,8 +64,8 @@ var WhiteboardQuery = common.Shortcut{
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true},
|
||||
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
|
||||
{Name: "output_as", Desc: "output whiteboard as: image | code | raw.", Required: true},
|
||||
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as code/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
},
|
||||
HasFormat: true,
|
||||
@@ -101,8 +86,8 @@ var WhiteboardQuery = common.Shortcut{
|
||||
}
|
||||
|
||||
as := runtime.Str("output_as")
|
||||
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsSvg && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
|
||||
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | code | raw").WithParam("--output_as")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -122,13 +107,8 @@ var WhiteboardQuery = common.Shortcut{
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract raw nodes structure from given whiteboard")
|
||||
case WhiteboardQueryAsSvg:
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
|
||||
Body(map[string]string{"export_type": "svg"}).
|
||||
Desc("Export SVG of given whiteboard")
|
||||
default:
|
||||
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
|
||||
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | code | raw")
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -139,110 +119,17 @@ var WhiteboardQuery = common.Shortcut{
|
||||
switch as {
|
||||
case WhiteboardQueryAsImage:
|
||||
return exportWhiteboardPreview(ctx, runtime, token, outDir)
|
||||
case WhiteboardQueryAsSvg:
|
||||
return exportWhiteboardSvg(runtime, token, outDir)
|
||||
case WhiteboardQueryAsCode:
|
||||
return exportWhiteboardCode(runtime, token, outDir)
|
||||
case WhiteboardQueryAsRaw:
|
||||
return exportWhiteboardRaw(runtime, token, outDir)
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | code | raw").WithParam("--output_as")
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
// exportReq defines the request body for whiteboard export APIs.
|
||||
type exportReq struct {
|
||||
ExportType string `json:"export_type"`
|
||||
}
|
||||
|
||||
// exportResp models the whiteboard export response envelope.
|
||||
type exportResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Content string `json:"content"`
|
||||
MimeType string `json:"mime_type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// exportWhiteboardSvg exports a whiteboard as SVG and writes the result to stdout or a file.
|
||||
// It requests the SVG export for the given whiteboard token and saves the decoded content when an output path is provided.
|
||||
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
reqBody := exportReq{ExportType: "svg"}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
|
||||
}
|
||||
|
||||
var exportData exportResp
|
||||
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
|
||||
if exportData.Code != 0 {
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
|
||||
}
|
||||
} else if resp.StatusCode == http.StatusOK {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_content": string(svgBytes),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(svgBytes))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "File size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// exportWhiteboardPreview downloads a whiteboard preview image and saves it as a PNG file.
|
||||
//
|
||||
// It reports the saved file path and image size on success.
|
||||
// Returns an error if the API request fails, the response is rejected, or the file cannot be saved.
|
||||
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
@@ -444,9 +331,6 @@ func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveOutputFile writes exported content to a file or directory and returns the final path and written size.
|
||||
// If outPath is a directory, it creates a file named whiteboard_<token><ext>. If outPath is a file path,
|
||||
// it adjusts the file extension to ext, validates the path, and respects the overwrite flag.
|
||||
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
// Step 1: Get final output path
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
@@ -483,8 +367,6 @@ func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext,
|
||||
switch ext {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".svg":
|
||||
contentType = "image/svg+xml"
|
||||
case ".json":
|
||||
contentType = "application/json"
|
||||
case ".mmd", ".puml":
|
||||
|
||||
@@ -6,8 +6,6 @@ package whiteboard
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -15,7 +13,6 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -23,7 +20,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestSyntaxType verifies syntax names, extensions, and validity checks.
|
||||
func TestSyntaxType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -79,7 +75,6 @@ func TestSyntaxType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardQuery_Validate verifies query flag validation for supported output modes.
|
||||
func TestWhiteboardQuery_Validate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
chdirTemp(t)
|
||||
@@ -204,9 +199,6 @@ func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -240,7 +232,6 @@ func TestExportWhiteboardPreview_HTTPError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardPreview_HTTPNotFoundIsAPIError verifies 404 preview downloads surface as typed API errors.
|
||||
func TestExportWhiteboardPreview_HTTPNotFoundIsAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
@@ -264,7 +255,6 @@ func TestExportWhiteboardPreview_HTTPNotFoundIsAPIError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardQuery_DryRun verifies dry-run output for the supported query modes.
|
||||
func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -317,64 +307,6 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardQuery_DryRun_InvalidOutputAs verifies dry-run guidance for unsupported output modes.
|
||||
func TestWhiteboardQuery_DryRun_InvalidOutputAs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "invalid",
|
||||
}, nil)
|
||||
|
||||
dryRun := WhiteboardQuery.DryRun(ctx, rt)
|
||||
if dryRun == nil {
|
||||
t.Fatal("WhiteboardQuery.DryRun() returned nil")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "image | svg | code | raw") {
|
||||
t.Fatalf("dry run desc = %s, want invalid output_as guidance", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardQuery_Execute_InvalidOutputAs_TypedError verifies invalid output modes return typed validation errors.
|
||||
func TestWhiteboardQuery_Execute_InvalidOutputAs_TypedError(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "invalid",
|
||||
}, nil)
|
||||
|
||||
err := WhiteboardQuery.Execute(context.Background(), rt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--output_as" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--output_as")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("errs.ProblemOf returned false")
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardQuery_ShortcutRegistration verifies the whiteboard query shortcut metadata.
|
||||
func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -393,7 +325,6 @@ func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveOutputFile verifies output saving, overwrite handling, and extension-specific paths.
|
||||
func TestSaveOutputFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -545,7 +476,6 @@ func TestSaveOutputFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveOutputFile_InvalidFinalPathTypedError verifies invalid save paths return typed validation errors.
|
||||
func TestSaveOutputFile_InvalidFinalPathTypedError(t *testing.T) {
|
||||
chdirTemp(t)
|
||||
|
||||
@@ -561,19 +491,6 @@ func TestSaveOutputFile_InvalidFinalPathTypedError(t *testing.T) {
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--output" {
|
||||
t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("errs.ProblemOf returned false")
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if !errors.Is(err, fileio.ErrPathValidation) {
|
||||
t.Fatalf("expected path-validation cause to be preserved, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) {
|
||||
@@ -608,7 +525,6 @@ func runShortcut(t *testing.T, shortcut common.Shortcut, args []string, factory
|
||||
return err
|
||||
}
|
||||
|
||||
// TestWhiteboardQueryExecute_AsRaw verifies raw query execution emits the raw node payload.
|
||||
func TestWhiteboardQueryExecute_AsRaw(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -637,7 +553,6 @@ func TestWhiteboardQueryExecute_AsRaw(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardQueryExecute_AsCode verifies code query execution emits extracted diagram source.
|
||||
func TestWhiteboardQueryExecute_AsCode(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
@@ -668,7 +583,6 @@ func TestWhiteboardQueryExecute_AsCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardCode_EmptyNodes verifies code export handles empty whiteboards.
|
||||
func TestExportWhiteboardCode_EmptyNodes(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -691,7 +605,6 @@ func TestExportWhiteboardCode_EmptyNodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardCode_NoCodeBlocks verifies code export reports whiteboards without code blocks.
|
||||
func TestExportWhiteboardCode_NoCodeBlocks(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -716,7 +629,6 @@ func TestExportWhiteboardCode_NoCodeBlocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardCode_InvalidSyntaxType verifies unknown syntax types are rejected.
|
||||
func TestExportWhiteboardCode_InvalidSyntaxType(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -746,7 +658,6 @@ func TestExportWhiteboardCode_InvalidSyntaxType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardCode_MultipleCodeBlocks verifies multiple code blocks are exported together.
|
||||
func TestExportWhiteboardCode_MultipleCodeBlocks(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -786,7 +697,6 @@ func TestExportWhiteboardCode_MultipleCodeBlocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput verifies direct PlantUML output for a single code block.
|
||||
func TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -820,7 +730,6 @@ func TestExportWhiteboardCode_SingleBlock_PlantUML_DirectOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput verifies direct Mermaid output for a single code block.
|
||||
func TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -854,7 +763,6 @@ func TestExportWhiteboardCode_SingleBlock_Mermaid_DirectOutput(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardPreview verifies preview downloads can be written to disk.
|
||||
func TestExportWhiteboardPreview(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -883,7 +791,6 @@ func TestExportWhiteboardPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardRaw_EmptyNodes verifies raw export reports empty whiteboards.
|
||||
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -906,7 +813,6 @@ func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchWhiteboardNodes_APIError verifies node fetch failures preserve typed API errors.
|
||||
func TestFetchWhiteboardNodes_APIError(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
@@ -936,7 +842,6 @@ func TestFetchWhiteboardNodes_APIError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestFetchWhiteboardNodes_InvalidResponseTypedError verifies malformed node responses become typed invalid-response errors.
|
||||
func TestFetchWhiteboardNodes_InvalidResponseTypedError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -996,474 +901,6 @@ func TestFetchWhiteboardNodes_MissingNodesIsEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_DirectOutput verifies SVG export is printed when no output path is provided.
|
||||
func TestExportWhiteboardSvg_DirectOutput(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>`
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg/export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
|
||||
"mime_type": "image/svg+xml",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg", "--output_as", "svg"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(stdout.String(), "svg_content") {
|
||||
t.Fatalf("stdout missing svg_content key: %s", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_SaveToFile verifies SVG export is written to the requested file.
|
||||
func TestExportWhiteboardSvg_SaveToFile(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><circle cx="50" cy="50" r="40"/></svg>`
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg-file/export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
|
||||
"mime_type": "image/svg+xml",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg-file", "--output_as", "svg", "--output", "output", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile("output.svg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != svgContent {
|
||||
t.Fatalf("svg content = %q, want %q", string(data), svgContent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_PrettyOutput verifies pretty output includes inline SVG content.
|
||||
func TestExportWhiteboardSvg_PrettyOutput(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><path d="M0 0L10 10"/></svg>`
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg-pretty/export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
|
||||
"mime_type": "image/svg+xml",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg-pretty", "--output_as", "svg", "--format", "pretty"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, svgContent) {
|
||||
t.Fatalf("stdout = %q, want svg content", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_SaveToFile_PrettyOutput verifies pretty output reports the saved SVG path and size.
|
||||
func TestExportWhiteboardSvg_SaveToFile_PrettyOutput(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><ellipse cx="60" cy="40" rx="50" ry="30"/></svg>`
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg-file-pretty/export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
|
||||
"mime_type": "image/svg+xml",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg-file-pretty", "--output_as", "svg", "--output", "output", "--overwrite", "--format", "pretty"}
|
||||
if err := runShortcut(t, WhiteboardQuery, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if got := stdout.String(); !strings.Contains(got, "SVG saved to output.svg") || !strings.Contains(got, "File size:") {
|
||||
t.Fatalf("stdout = %q, want save summary", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_SaveToFile_ExistingFileWithoutOverwrite verifies existing SVG outputs require --overwrite.
|
||||
func TestExportWhiteboardSvg_SaveToFile_ExistingFileWithoutOverwrite(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
if err := os.WriteFile("output.svg", []byte("existing content"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
|
||||
svgContent := `<svg xmlns="http://www.w3.org/2000/svg"><line x1="0" y1="0" x2="1" y2="1"/></svg>`
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg-existing/export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"content": base64.StdEncoding.EncodeToString([]byte(svgContent)),
|
||||
"mime_type": "image/svg+xml",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg-existing", "--output_as", "svg", "--output", "output"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for existing output without overwrite")
|
||||
}
|
||||
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if ve.Param != "--overwrite" {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, "--overwrite")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_HTTP5xx verifies plain HTTP 5xx failures are classified as retryable network errors.
|
||||
func TestExportWhiteboardSvg_HTTP5xx(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg-5xx/export",
|
||||
Status: 502,
|
||||
RawBody: []byte("bad gateway"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg-5xx", "--output_as", "svg"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 502")
|
||||
}
|
||||
var ne *errs.NetworkError
|
||||
if !errors.As(err, &ne) {
|
||||
t.Fatalf("error is not *errs.NetworkError: %T (%v)", err, err)
|
||||
}
|
||||
if ne.Subtype != errs.SubtypeNetworkServer {
|
||||
t.Errorf("Subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkServer)
|
||||
}
|
||||
if ne.Code != 502 {
|
||||
t.Errorf("Code = %d, want 502", ne.Code)
|
||||
}
|
||||
if !ne.Retryable {
|
||||
t.Error("expected Retryable = true")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_HTTP5xxJSONEnvelopeReturnsAPIError verifies API envelopes take precedence over generic 5xx handling.
|
||||
func TestExportWhiteboardSvg_HTTP5xxJSONEnvelopeReturnsAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg-5xx-json/export",
|
||||
Status: 502,
|
||||
ContentType: "application/json",
|
||||
RawBody: []byte(`{"code":99002,"msg":"export task failed"}`),
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg-5xx-json", "--output_as", "svg"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 502 JSON envelope")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
|
||||
}
|
||||
var ne *errs.NetworkError
|
||||
if errors.As(err, &ne) {
|
||||
t.Fatalf("expected JSON envelope to win over HTTP 5xx fallback, got *errs.NetworkError: %v", err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if apiErr.Code != 99002 {
|
||||
t.Errorf("Code = %d, want 99002", apiErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_HTTP4xx verifies plain HTTP 4xx failures are surfaced as API errors.
|
||||
func TestExportWhiteboardSvg_HTTP4xx(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg-403/export",
|
||||
Status: 403,
|
||||
RawBody: []byte("forbidden"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg-403", "--output_as", "svg"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 403")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeUnknown {
|
||||
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeUnknown)
|
||||
}
|
||||
if apiErr.Code != 403 {
|
||||
t.Errorf("Code = %d, want 403", apiErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_HTTPNotFoundJSONEnvelopeIsAPIError verifies not-found envelopes preserve the typed API error classification.
|
||||
func TestExportWhiteboardSvg_HTTPNotFoundJSONEnvelopeIsAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/missing-token-svg/export",
|
||||
Status: 404,
|
||||
ContentType: "application/json",
|
||||
RawBody: []byte(`{"code":99001,"msg":"whiteboard not found"}`),
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "missing-token-svg", "--output_as", "svg"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 404 JSON envelope")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeNotFound {
|
||||
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
if apiErr.Code != 99001 {
|
||||
t.Errorf("Code = %d, want 99001", apiErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_HTTPNotFoundPlainText verifies plain-text 404 responses surface as not-found API errors.
|
||||
func TestExportWhiteboardSvg_HTTPNotFoundPlainText(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/missing-token-svg-plain/export",
|
||||
Status: 404,
|
||||
ContentType: "text/plain",
|
||||
RawBody: []byte("whiteboard not found"),
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "missing-token-svg-plain", "--output_as", "svg"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for HTTP 404 plain text response")
|
||||
}
|
||||
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
|
||||
}
|
||||
if apiErr.Subtype != errs.SubtypeNotFound {
|
||||
t.Errorf("Subtype = %q, want %q", apiErr.Subtype, errs.SubtypeNotFound)
|
||||
}
|
||||
if apiErr.Code != 404 {
|
||||
t.Errorf("Code = %d, want 404", apiErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_InvalidJSON verifies malformed success responses are rejected as invalid responses.
|
||||
func TestExportWhiteboardSvg_InvalidJSON(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg-badjson/export",
|
||||
Status: 200,
|
||||
RawBody: []byte("not json at all"),
|
||||
ContentType: "application/json",
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg-badjson", "--output_as", "svg"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JSON")
|
||||
}
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_InvalidBody200PlainText verifies plain-text 200 responses are rejected as invalid export responses.
|
||||
func TestExportWhiteboardSvg_InvalidBody200PlainText(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg-plain-200/export",
|
||||
Status: 200,
|
||||
RawBody: []byte("not json at all"),
|
||||
ContentType: "text/plain",
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg-plain-200", "--output_as", "svg"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for plain text success response")
|
||||
}
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_NonZeroCode verifies non-zero API codes are returned as typed API errors.
|
||||
func TestExportWhiteboardSvg_NonZeroCode(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg-apierr/export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99001,
|
||||
"msg": "whiteboard not found",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg-apierr", "--output_as", "svg"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-zero code")
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("error is not *errs.APIError: %T (%v)", err, err)
|
||||
}
|
||||
if apiErr.Code != 99001 {
|
||||
t.Errorf("Code = %d, want 99001", apiErr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardSvg_InvalidBase64 verifies invalid SVG payload encoding is rejected.
|
||||
func TestExportWhiteboardSvg_InvalidBase64(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg-badbase64/export",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"content": "!!!not-valid-base64!!!",
|
||||
"mime_type": "image/svg+xml",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-svg-badbase64", "--output_as", "svg"}
|
||||
err := runShortcut(t, WhiteboardQuery, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid base64")
|
||||
}
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestWhiteboardQuery_Validate_SvgValid verifies svg is accepted as a valid query output format.
|
||||
func TestWhiteboardQuery_Validate_SvgValid(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
chdirTemp(t)
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "svg",
|
||||
}, nil)
|
||||
if err := WhiteboardQuery.Validate(ctx, rt); err != nil {
|
||||
t.Fatalf("expected svg to be valid, got err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardQuery_DryRun_Svg verifies the svg dry-run request uses the export endpoint and body.
|
||||
func TestWhiteboardQuery_DryRun_Svg(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output_as": "svg",
|
||||
}, nil)
|
||||
dryRun := WhiteboardQuery.DryRun(ctx, rt)
|
||||
if dryRun == nil {
|
||||
t.Fatal("DryRun() returned nil for svg")
|
||||
}
|
||||
|
||||
data, err := json.Marshal(dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v", err)
|
||||
}
|
||||
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("len(api) = %d, want 1", len(got.API))
|
||||
}
|
||||
if got.API[0].Method != "POST" {
|
||||
t.Fatalf("method = %q, want POST", got.API[0].Method)
|
||||
}
|
||||
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...-123/export" {
|
||||
t.Fatalf("url = %q", got.API[0].URL)
|
||||
}
|
||||
if got.API[0].Body["export_type"] != "svg" {
|
||||
t.Fatalf("body = %#v, want export_type=svg", got.API[0].Body)
|
||||
}
|
||||
if _, ok := got.API[0].Params["export_type"]; ok {
|
||||
t.Fatalf("params should not include export_type, got %#v", got.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
// assertInvalidResponse verifies an error is classified as a typed invalid-response failure.
|
||||
func assertInvalidResponse(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
|
||||
@@ -17,21 +17,15 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// FormatRaw sends raw whiteboard node JSON to the create-nodes API.
|
||||
FormatRaw = "raw"
|
||||
// FormatPlantUML sends PlantUML source through the diagram import API.
|
||||
FormatRaw = "raw"
|
||||
FormatPlantUML = "plantuml"
|
||||
// FormatMermaid sends Mermaid source through the diagram import API.
|
||||
FormatMermaid = "mermaid"
|
||||
// FormatSVG sends SVG source through the diagram import API.
|
||||
FormatSVG = "svg"
|
||||
FormatMermaid = "mermaid"
|
||||
)
|
||||
|
||||
var formatCodeMap = map[string]int{
|
||||
FormatRaw: 0,
|
||||
FormatPlantUML: 1,
|
||||
FormatMermaid: 2,
|
||||
FormatSVG: 3,
|
||||
}
|
||||
|
||||
var wbUpdateScopes = []string{"board:whiteboard:node:create"}
|
||||
@@ -41,14 +35,9 @@ var wbUpdateFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update. You will need edit permission to update the whiteboard.", Required: true},
|
||||
{Name: "overwrite", Desc: "overwrite the whiteboard content, delete all existing content before update. Default is false.", Required: false, Type: "bool"},
|
||||
{Name: "source", Desc: "Input whiteboard data.", Required: true, Input: []string{common.Stdin, common.File}},
|
||||
{Name: "input_format", Desc: "format of input data: raw | plantuml | mermaid | svg. Default is raw.", Required: false},
|
||||
{Name: "input_format", Desc: "format of input data: raw | plantuml | mermaid. Default is raw.", Required: false},
|
||||
}
|
||||
|
||||
// wbUpdateValidate validates the whiteboard update command arguments.
|
||||
//
|
||||
// It checks the whiteboard token and idempotent token for dangerous control
|
||||
// characters, enforces a minimum length for a non-empty idempotent token, and
|
||||
// ensures the input format is one of raw, plantuml, mermaid, or svg.
|
||||
func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 检查 token 是否包含控制字符(空字符串下自动跳过了)
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
|
||||
@@ -64,8 +53,8 @@ func wbUpdateValidate(ctx context.Context, runtime *common.RuntimeContext) error
|
||||
|
||||
// 检查 --input_format 标志
|
||||
format := getFormat(runtime)
|
||||
if format != FormatRaw && format != FormatPlantUML && format != FormatMermaid && format != FormatSVG {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input_format must be one of: raw | plantuml | mermaid | svg").WithParam("--input_format")
|
||||
if format != FormatRaw && format != FormatPlantUML && format != FormatMermaid {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--input_format must be one of: raw | plantuml | mermaid").WithParam("--input_format")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -79,8 +68,6 @@ func getFormat(runtime *common.RuntimeContext) string {
|
||||
return format
|
||||
}
|
||||
|
||||
// wbUpdateDryRun describes the HTTP request used to update a whiteboard.
|
||||
// It returns a failure description when source is missing or cannot be parsed.
|
||||
func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// 读取输入内容
|
||||
input := runtime.Str("source")
|
||||
@@ -104,7 +91,7 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
|
||||
Overwrite: overwrite,
|
||||
}
|
||||
desc.POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).Body(reqBody).Desc("create all nodes of the whiteboard.")
|
||||
case FormatPlantUML, FormatMermaid, FormatSVG:
|
||||
case FormatPlantUML, FormatMermaid:
|
||||
syntaxType := formatCodeMap[format]
|
||||
reqBody := plantumlCreateReq{
|
||||
PlantUmlCode: input,
|
||||
@@ -119,10 +106,6 @@ func wbUpdateDryRun(ctx context.Context, runtime *common.RuntimeContext) *common
|
||||
return desc
|
||||
}
|
||||
|
||||
// wbUpdateExecute updates a whiteboard from the supplied source input.
|
||||
// It requires --source and dispatches to the raw node update path for raw input
|
||||
// or the diagram import path for PlantUML, Mermaid, and SVG input.
|
||||
// It returns an error if the source is missing or the input format is unsupported.
|
||||
func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
token := runtime.Str("whiteboard-token")
|
||||
overwrite := runtime.Bool("overwrite")
|
||||
@@ -137,17 +120,15 @@ func wbUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error
|
||||
switch format {
|
||||
case FormatRaw:
|
||||
return updateWhiteboardByRawNodes(ctx, runtime, token, []byte(input), overwrite, idempotentToken)
|
||||
case FormatPlantUML, FormatMermaid, FormatSVG:
|
||||
case FormatPlantUML, FormatMermaid:
|
||||
return updateWhiteboardByCode(ctx, runtime, token, []byte(input), format, overwrite, idempotentToken)
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported format: %s", format).WithParam("--input_format")
|
||||
}
|
||||
}
|
||||
|
||||
// WhiteboardUpdateDescription describes the whiteboard update shortcut.
|
||||
const WhiteboardUpdateDescription = "Update an existing whiteboard in lark document with mermaid, plantuml or whiteboard dsl. refer to lark-whiteboard skill for more details."
|
||||
|
||||
// WhiteboardUpdate registers the `whiteboard +update` shortcut.
|
||||
var WhiteboardUpdate = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+update",
|
||||
|
||||
@@ -6,7 +6,6 @@ package whiteboard
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestWhiteboardUpdate_Validate verifies update flag validation for supported input formats.
|
||||
func TestWhiteboardUpdate_Validate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -55,15 +53,6 @@ func TestWhiteboardUpdate_Validate(t *testing.T) {
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: svg format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "svg",
|
||||
"source": "<svg/>",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid: with idempotent-token",
|
||||
flags: map[string]string{
|
||||
@@ -128,26 +117,25 @@ func TestWhiteboardUpdate_Validate_TypedErrors(t *testing.T) {
|
||||
"idempotent-token": "short",
|
||||
"source": "{}",
|
||||
}, nil)
|
||||
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--idempotent-token", false)
|
||||
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--idempotent-token")
|
||||
})
|
||||
|
||||
t.Run("bad input_format", func(t *testing.T) {
|
||||
rt := newTestRuntime(map[string]string{
|
||||
"whiteboard-token": "t",
|
||||
"input_format": "png",
|
||||
"input_format": "svg",
|
||||
"source": "{}",
|
||||
}, nil)
|
||||
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--input_format", false)
|
||||
assertValidationParam(t, wbUpdateValidate(ctx, rt), "--input_format")
|
||||
})
|
||||
|
||||
t.Run("malformed source json", func(t *testing.T) {
|
||||
_, err, _ := parseWBcliNodes([]byte("not-json"))
|
||||
assertValidationParam(t, err, "--source", true)
|
||||
assertValidationParam(t, err, "--source")
|
||||
})
|
||||
}
|
||||
|
||||
// assertValidationParam verifies a validation error carries the expected flag param.
|
||||
func assertValidationParam(t *testing.T, err error, wantParam string, wantJSONCause bool) {
|
||||
func assertValidationParam(t *testing.T, err error, wantParam string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
@@ -162,25 +150,8 @@ func assertValidationParam(t *testing.T, err error, wantParam string, wantJSONCa
|
||||
if ve.Param != wantParam {
|
||||
t.Errorf("Param = %q, want %q", ve.Param, wantParam)
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("errs.ProblemOf returned false")
|
||||
}
|
||||
if p.Category != errs.CategoryValidation {
|
||||
t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Errorf("Problem subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument)
|
||||
}
|
||||
if wantJSONCause {
|
||||
var syntaxErr *json.SyntaxError
|
||||
if !errors.As(err, &syntaxErr) {
|
||||
t.Fatalf("expected json syntax cause to be preserved, err=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetFormat verifies input format defaults and explicit format selection.
|
||||
func TestGetFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -209,11 +180,6 @@ func TestGetFormat(t *testing.T) {
|
||||
flagVal: FormatMermaid,
|
||||
expected: FormatMermaid,
|
||||
},
|
||||
{
|
||||
name: "svg returns svg",
|
||||
flagVal: FormatSVG,
|
||||
expected: FormatSVG,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -227,7 +193,6 @@ func TestGetFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdate_ShortcutRegistration verifies the shortcut metadata for update commands.
|
||||
func TestWhiteboardUpdate_ShortcutRegistration(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -248,7 +213,6 @@ func TestWhiteboardUpdate_ShortcutRegistration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestShortcutsIncludesExpectedCommands verifies the whiteboard shortcut registry includes query and update.
|
||||
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -273,7 +237,6 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseWBcliNodes verifies whiteboard CLI output parsing for raw and wrapped node payloads.
|
||||
func TestParseWBcliNodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -322,7 +285,6 @@ func TestParseWBcliNodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWBUpdateDryRun verifies dry-run requests for the supported whiteboard update formats.
|
||||
func TestWBUpdateDryRun(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -355,14 +317,6 @@ func TestWBUpdateDryRun(t *testing.T) {
|
||||
"source": "graph TD\nA-->B",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dry run svg format",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"input_format": "svg",
|
||||
"source": "<svg/>",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -408,7 +362,6 @@ func runUpdateShortcut(t *testing.T, shortcut common.Shortcut, args []string, fa
|
||||
return err
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_RawFormat verifies raw node updates call the raw nodes endpoint.
|
||||
func TestWhiteboardUpdateExecute_RawFormat(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
@@ -432,7 +385,6 @@ func TestWhiteboardUpdateExecute_RawFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_PlantUMLFormat verifies PlantUML updates use the diagram import endpoint.
|
||||
func TestWhiteboardUpdateExecute_PlantUMLFormat(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
@@ -458,7 +410,6 @@ Bob -> Alice : hello
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_PlantUMLInvalidResponse verifies missing node IDs are treated as invalid responses.
|
||||
func TestWhiteboardUpdateExecute_PlantUMLInvalidResponse(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
@@ -480,7 +431,6 @@ Bob -> Alice : hello
|
||||
assertInvalidResponse(t, err)
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_MermaidFormat verifies Mermaid updates use the diagram import endpoint.
|
||||
func TestWhiteboardUpdateExecute_MermaidFormat(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
@@ -505,44 +455,6 @@ A-->B`
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_SVGFormat verifies svg update requests use syntax_type=3 and send the source payload.
|
||||
func TestWhiteboardUpdateExecute_SVGFormat(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
// SVG shares the /nodes/plantuml endpoint with plantuml/mermaid via syntax_type=3.
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-svg/nodes/plantuml",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"node_id": "node1",
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
source := `<svg xmlns="http://www.w3.org/2000/svg"/>`
|
||||
args := []string{"+update", "--whiteboard-token", "test-token-svg", "--input_format", "svg", "--source", source}
|
||||
if err := runUpdateShortcut(t, WhiteboardUpdate, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
|
||||
}
|
||||
|
||||
if got := body["syntax_type"]; got != float64(3) {
|
||||
t.Fatalf("syntax_type = %#v, want 3; body=%s", got, string(stub.CapturedBody))
|
||||
}
|
||||
if got := body["plant_uml_code"]; got != source {
|
||||
t.Fatalf("plant_uml_code = %#v, want %q; body=%s", got, source, string(stub.CapturedBody))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_RawInvalidResponse verifies malformed raw update responses are rejected.
|
||||
func TestWhiteboardUpdateExecute_RawInvalidResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -582,7 +494,6 @@ func TestWhiteboardUpdateExecute_RawInvalidResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_RawWithIdempotent verifies raw updates pass through the idempotency token.
|
||||
func TestWhiteboardUpdateExecute_RawWithIdempotent(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
@@ -607,7 +518,6 @@ func TestWhiteboardUpdateExecute_RawWithIdempotent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_RawFormatWithRawNodes verifies raw-node payloads are forwarded without DSL wrapping.
|
||||
func TestWhiteboardUpdateExecute_RawFormatWithRawNodes(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
@@ -631,7 +541,6 @@ func TestWhiteboardUpdateExecute_RawFormatWithRawNodes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_RawAPIError verifies raw update API failures preserve typed error metadata and hints.
|
||||
func TestWhiteboardUpdateExecute_RawAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
@@ -668,7 +577,6 @@ func TestWhiteboardUpdateExecute_RawAPIError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_PlantUMLAPIError verifies diagram update API failures preserve typed error metadata.
|
||||
func TestWhiteboardUpdateExecute_PlantUMLAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
@@ -699,7 +607,6 @@ invalid
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_WithOverwrite verifies diagram updates send overwrite=true when requested.
|
||||
func TestWhiteboardUpdateExecute_WithOverwrite(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
@@ -724,7 +631,6 @@ A-->B`
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardUpdateExecute_RawWithOverwrite verifies raw updates send overwrite=true when requested.
|
||||
func TestWhiteboardUpdateExecute_RawWithOverwrite(t *testing.T) {
|
||||
factory, stdout, reg := newUpdateExecuteFactory(t)
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
| `docx` | 新版云文档 | `drive file.comments.*`、`docx.*` |
|
||||
| `doc` | 旧版云文档 | `drive file.comments.*` |
|
||||
| `sheet` | 电子表格 | `sheets.*` |
|
||||
| `bitable` | 多维表格 / Base | `drive file.comments.*`、`bitable.*` |
|
||||
| `bitable` | 多维表格 | `bitable.*` |
|
||||
| `slides` | 幻灯片 | `drive.*` |
|
||||
| `file` | 文件 | `drive.*` |
|
||||
| `mindnote` | 思维导图 | `drive.*` |
|
||||
@@ -112,8 +112,8 @@ Drive Folder (云空间文件夹)
|
||||
| 操作 | 需要的 Token | 说明 |
|
||||
|------|-------------|------|
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `<sheetId>!<cell>`,`slides` 使用 `<slide-block-type>!<xml-id>`;Base / bitable 只有记录局部评论,定位为 file_token(base token) + `--block-id <table-id>!<record-id>!<view-id>` |
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL |
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
|
||||
| 列出文档评论 | `file_token` | 同添加评论 |
|
||||
@@ -121,15 +121,11 @@ Drive Folder (云空间文件夹)
|
||||
### 评论能力边界(关键!)
|
||||
|
||||
- `drive +add-comment` 支持两种模式。
|
||||
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL。`sheet`、`slides`、Base / bitable 不支持全文评论。
|
||||
- 局部评论:传 `--block-id` 时启用;`docx` 支持文本定位或 block id,`sheet` 支持 `<sheetId>!<cell>`,`slides` 支持 `<slide-block-type>!<xml-id>`,Base / bitable 支持 `<table-id>!<record-id>!<view-id>`;wiki URL 解析到这些类型时也支持对应局部评论。Drive file 本次只支持全文评论,不支持局部评论。
|
||||
- Drive file 评论仅支持白名单扩展名:`.md`、`.txt`、`.json`、`.csv`、`.go`、`.js`、`.py`、`.pptx`、`.png`、`.jpg`、`.jpeg`、`.zip`、`.mp3`、`.mp4`。`.pdf`、`.docx`、`.xlsx` 等未在白名单内的普通文件暂不支持,CLI 会直接报错提示当前还不支持这种类型的评论。
|
||||
- Review / 审阅 / 校对 / 逐条指出问题场景优先使用局部评论,不要把多个可定位问题汇总成一条全文评论;具体参数和定位方式见生成后的 `skills/lark-drive/references/lark-drive-add-comment.md`。
|
||||
- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL。
|
||||
- 局部评论:传 `--block-id` 时启用;仅支持 `docx`,以及最终解析为 `docx` 的 wiki URL。block ID 可通过 `docs +fetch --api-version v2 --detail with-ids` 获取。
|
||||
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
|
||||
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`;CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。
|
||||
- Base 记录局部评论使用 `--type bitable` / `--type base` 或 `/base/`、`/bitable/`、wiki Base 链接;`bitable` 和 Base 是同一概念,`bitable` 是内部代号、Base 是产品名,裸 token 推荐传 `bitable`,`base` 仅作为兼容别名兜底。Base 不支持全局评论,所有评论都挂在记录上;定位信息必须是 file token(base token)+ `--block-id <table-id>!<record-id>!<view-id>`,其中 table/record/view ID 通常分别以 `tbl`/`rec`/`vew` 开头。view_id 只决定被提及时点击通知打开哪个视图,不影响评论挂载点,但必须传;ID 可通过 [`lark-base`](../../skills/lark-base/SKILL.md) 获取。
|
||||
- 如果 wiki 解析后不是 `doc`/`docx`/`file`/`sheet`/`slides`/`bitable`/`base`,不要用 `+add-comment`。
|
||||
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`;docx/sheet/slides 局部评论传 `anchor.block_id`,Base 记录局部评论传 `anchor.block_id`(table_id)、`anchor.base_record_id`、`anchor.base_view_id`。
|
||||
- 如果 wiki 解析后不是 `doc`/`docx`,不要用 `+add-comment`。
|
||||
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。
|
||||
|
||||
### 评论查询与统计口径(关键!)
|
||||
|
||||
@@ -193,7 +189,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
|
||||
|----------|------|----------|
|
||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet) |
|
||||
|
||||
### 授权当前应用访问文档
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-apps
|
||||
version: 1.0.0
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -48,14 +48,8 @@ metadata:
|
||||
|
||||
- **发布意图判定**:用户要"可访问 / 线上 / 分享 / 新链接 / 上线" = 发布意图,先走发布链路、确认完成再给链接。
|
||||
- 完成 ≠ 发布:云端会话完成 / `+list is_published=true` 都不代表最新内容已部署。
|
||||
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}`:进应用编辑/开发态、管理与继续开发应用的入口。发布成功后,连同发布态链接一并提供给用户(说明"管理 / 继续开发去这里");但它仅进编辑态,**不能**顶替发布态链接当分享链接。
|
||||
- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}` 仅进编辑态,不能顶替发布当分享链接。
|
||||
- 发布态链接来源:html → `+html-publish` 的 `data.url`;全栈 → `+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`。
|
||||
- **可见范围**:发布态链接(html 的 `data.url`、全栈的 `online_url`)默认仅**创建者可见**,发给他人对方会无权限打不开。当可分享链接交付给用户前,先告知当前仅本人可见,再询问是否用 `+access-scope-set`(`tenant`/`public`/`specific`)放开(可先 `+access-scope-get` 查当前范围)。
|
||||
|
||||
## 能力边界
|
||||
|
||||
- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)/ 自动化 / 插件。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。
|
||||
- 用户要配置权限 / 自动化 / 插件时,引导其使用开发态连接前往云端开发(妙搭 web)处理。
|
||||
|
||||
## app_id 获取
|
||||
|
||||
@@ -75,4 +69,4 @@ metadata:
|
||||
## 高影响动作:确认与预授权
|
||||
|
||||
- **预授权判定**:判断用户是否表达了"放手做完、不用中途逐步问我"的意图——明确免确认(如"别问 / 直接做 / 自己定"),或要求一气呵成做到完成(如"做完部署上线给我")。是 → 整个流程按合理默认往下走、不再逐步确认(含 clone 到派生目录、发布等);否 → 缺失参数(如目录)该问就问、高影响动作先确认。
|
||||
- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项。
|
||||
- **不豁免底线**:会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))即便已预授权,也先 `--dry-run` 确认。
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- 必填:`--app-id`、`--path`。
|
||||
- `--path` 可以是单个文件或目录;入口必须是 `index.html`。
|
||||
- 可选:`--allow-sensitive`,跳过凭据文件扫描。
|
||||
- 客户端打包 tar.gz 上传发布。三条硬性大小限制,任一超限即被客户端拒绝、无法发布:单个 `.html` 文件 ≤ 10MB、打包后 tar.gz ≤ 20MB、未压缩候选文件总量 ≤ 200MB。
|
||||
- 客户端会打包 tar.gz 并上传发布;压缩包上限当前为 20MB,未压缩候选文件总量也有保护上限。
|
||||
|
||||
## 示例
|
||||
|
||||
@@ -33,19 +33,12 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
|
||||
- 发布态访问链接以本命令成功返回的 `data.url` 为准。
|
||||
- 重新发布前,`+list` 的 `is_published=true` 只能说明历史上发布过,不代表当前本地产物已经部署。
|
||||
|
||||
## 发布前置门(第一步,先于任何其他动作)
|
||||
|
||||
收到发布意图后,第一个动作是量三个尺寸,不是读文件内容、不是打包:
|
||||
1. 单个 `.html` ≤ 10MB / tar.gz ≤ 20MB / 未压缩总量 ≤ 200MB。
|
||||
2. 任一超限 → 立即 STOP,把超限数字转述给用户,交还决定权。
|
||||
3. 三项都通过 → 才进入下面的命令骨架。
|
||||
|
||||
## 预览与发布边界
|
||||
|
||||
- 用户只说“用 HTML 写个 PPT/页面给我看看”时,先生成本地文件或目录,返回路径并问是否发布到妙搭分享;不要默认创建应用或部署。
|
||||
- 用户明确说“部署出去/发链接/可分享”时,才创建 `html` 应用并用 `+html-publish`。
|
||||
- 用户要发布但没有 app_id 时,先 `+create --app-type html` 创建应用;应用名可从页面/站点主题生成,不要让用户手动提供 app_id。
|
||||
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist`。`.git` 目录会被自动排除,不会进入压缩包。
|
||||
- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist`。`.git` 目录会被自动排除,不会进入压缩包;`node_modules`、源码缓存等仍建议手动精简以控制包体。
|
||||
- 重新部署同一个 HTML 应用时复用原 `app_id`,只重新执行 `+html-publish --app-id <id> --path <dir-or-index.html>`。
|
||||
|
||||
## 安全规则
|
||||
@@ -55,3 +48,4 @@ lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run
|
||||
## 常见失败
|
||||
|
||||
- 缺少 `index.html`:目录根放置 `index.html`,或单文件路径直接指向名为 `index.html` 的文件。
|
||||
- 包体过大:让用户精简 `--path`,不要把源码、依赖目录、构建缓存一起发布。
|
||||
|
||||
@@ -31,7 +31,6 @@ lark-cli apps +init --app-id app_xxx --dir ./my-app --dry-run
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 目标目录必须不存在、为空目录,或已含 `.spark/meta.json` 且其 app_id 与 `--app-id` 一致的已初始化仓库。
|
||||
- 目标目录必须不存在、为空目录,或已含 `.spark/meta.json` 的已初始化仓库。
|
||||
- 目标目录已含 `.spark/meta.json` 时,`+init` 会跳过 clone/scaffold,但仍执行一次 env-pull 刷新本地环境变量;告知用户“仓库已初始化,本地环境变量已刷新,可直接开发”,不要误报失败或重复 clone。
|
||||
- `+init` 输出没有必要原样复述;告诉用户 clone path、分支和下一步即可。
|
||||
- 新建应用做本地初始化时,若选定的目标目录已存在,不要复用,改用一个不冲突的目录名(已预授权”放手做”时自动追加后缀如 `-2`;否则向用户确认目录名)。
|
||||
|
||||
@@ -26,7 +26,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||
2. **读取文档(`docs +fetch --api-version v2`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md));从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||
4. **需要使用 callout、grid、table、whiteboard 等富 block 时** → 参考 [`lark-doc-style.md`](references/style/lark-doc-style.md) 的元素能力说明。该文件不是固定模板或强制排版规范;除非用户明确要求美化、重排版或特定风格,不要为了“达标”主动套用固定结构。
|
||||
|
||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||
@@ -36,9 +36,11 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen
|
||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||
|
||||
## 快速决策
|
||||
- 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full`
|
||||
- block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids`
|
||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||
- 用户需要“某个 block 的直达链接 / 锚点链接”时:返回 `文档基础 URL#block_id`。如果当前只有文档 URL 没有 block_id,先用 `docs +fetch --detail with-ids` 拿到目标 block 的 id
|
||||
- 例:
|
||||
- 已知文档 URL = `https://xxx.feishu.cn/docx/doxcn123`
|
||||
- 已知 block_id = `blkcn456`
|
||||
- 应返回 `https://xxx.feishu.cn/docx/doxcn123#blkcn456`
|
||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
|
||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
|
||||
> **需要使用 callout、grid、table、whiteboard 等富 block,或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。该文件是表达组件参考,不是固定模板。**
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
@@ -74,7 +74,7 @@ lark-cli docs +create --api-version v2 --doc-format markdown --content $'# 项
|
||||
## 最佳实践
|
||||
|
||||
- 文档标题从内容中自动提取:XML 使用 `<title>`;Markdown 使用文档开头唯一的一级标题(`# 标题`),正文从 `##` 开始。不要在内容开头重复写标题,也不要在 Markdown 正文中使用多个一级标题。
|
||||
- **较长文档**:参考 [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) 先建骨架再分段写入;短文档可一次写完整内容。
|
||||
- **创建较长的文档时只建骨架**:`--content` 仅传标题 + 各级 heading + 简短占位摘要;正文留给后续 `block_insert_after --block-id <章节标题 block_id>` 分段追加。一次性塞超长 `--content` 既容易触发参数限制,调试也更难。
|
||||
- **表达形式**:由用户目标和内容决定。需要结构化表达时可参考 [`lark-doc-style.md`](style/lark-doc-style.md),但不要默认套用固定开头、固定富 block 比例或固定图表
|
||||
|
||||
## 参考
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||
> 2. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
||||
>
|
||||
> **需要富 block 或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。**
|
||||
> **需要使用 callout、grid、table、whiteboard 等富 block,或用户明确要求美化/重排版时,再参考 [`lark-doc-style.md`](style/lark-doc-style.md)。该文件是表达组件参考,不是固定模板。**
|
||||
>
|
||||
> **未读完以上文件就生成内容会导致格式错误。**
|
||||
|
||||
@@ -44,15 +44,6 @@
|
||||
| `append` | ⚠️ 在文档**末尾**追加内容(等价于 `block_insert_after --block-id -1`)。**不适用于逐章填充**——逐章写入请用 `block_insert_after` 并指定对应标题的 `--block-id` | `--content` |
|
||||
| `block_move_after` | 移动已有 block 到指定位置 | `--block-id` `--src-block-ids` |
|
||||
|
||||
## Block ID 生命周期
|
||||
|
||||
写操作后不要默认复用之前 fetch 到的 block ID:
|
||||
|
||||
- `overwrite` / `block_replace` / `block_delete`:受影响旧 ID 失效,继续 block 级操作前重新 fetch
|
||||
- `block_insert_after` / `append` / `block_copy_insert_after`:锚点 / 源 ID 通常保留,新内容是新 ID;要操作新内容先重新 fetch
|
||||
- `block_move_after`:被移动 ID 通常保留,但位置、章节、range 语义变化;后续依赖位置时重新 fetch
|
||||
- `str_replace`:简单行内替换通常不改变 ID;跨行 / 大段替换后如继续 block 级操作,先重新 fetch
|
||||
|
||||
## 指令示例
|
||||
|
||||
### str_replace — 全文文本替换
|
||||
@@ -123,6 +114,8 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command block_replace
|
||||
--content '<p>替换后的段落内容</p>'
|
||||
```
|
||||
|
||||
> `block_replace` 由服务端执行整块替换,目标 block 的 ID 不保证在替换后继续可用。后续如果还要在替换后的块附近继续 `block_insert_after`、`range` 或其他 block 级操作,先重新 `docs +fetch --detail with-ids` 获取最新 block ID,不要复用旧 ID。
|
||||
|
||||
### block_delete — 删除指定 block
|
||||
|
||||
```bash
|
||||
@@ -244,6 +237,7 @@ lark-cli docs +update --api-version v2 --doc "<doc_id>" --command str_replace \
|
||||
- **保护不可重建的内容**:图片、画板、电子表格等以 token 形式存储,替换时避开这些 block
|
||||
- **str_replace 的 replacement 支持富文本**:可以用行内标签 `<b>`、`<a>`、`<cite>`、`<latex>` 等替换普通文本为富文本
|
||||
- **同一 block 只能被 replace 一次**:多次修改同一 block 请合并为一次 block_replace
|
||||
- **block_replace 后重新获取 ID**:`block_replace` 成功后旧 block ID 不保证继续可用;继续做相邻块操作前,重新 `docs +fetch --detail with-ids`
|
||||
- **block_delete 支持批量**:用逗号分隔多个 block_id 一次删除
|
||||
- **复杂结构重组**:将多个段落转换为 grid / table 等复杂布局时,分步操作比 overwrite 更安全:
|
||||
1. 用 `block_insert_after` 在目标位置插入新的富文本结构
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
| `lark-doc` | 识别画板机会、使用 Mermaid/SVG 创建图表、调度 SubAgent、插入简单 SVG 画板或复杂空白画板 | 主 Agent 不直接创作画板内容; |
|
||||
| `lark-whiteboard` | 查询/导出已有画板;复杂图表生成(Mermaid/DSL/SVG 路由、场景选型、渲染验证);写入已有/空白画板 | 仅特别复杂的图表或已有画板更新时由独立 SubAgent 读取 |
|
||||
|
||||
## 画板适用规则
|
||||
## 画板优先规则
|
||||
|
||||
写文档时,核心流程、系统架构、方案对比、风险链路、里程碑、指标趋势、因果归因、组织关系、能力分层等内容,如果图示能明显降低理解成本,可以规划为画板;结构简单或文字更清楚的内容不必强行画板化。
|
||||
写文档时,重要信息优先画板化。遇到核心流程、系统架构、方案对比、风险链路、里程碑、指标趋势、因果归因、组织关系、能力分层等内容,不要只用段落或表格承载;除非内容只是一次性补充说明,否则应规划为画板。
|
||||
|
||||
同一篇文档可以有多个画板。确有多个独立图示点时,可拆成多个聚焦画板,而不是把所有信息塞进一张大图。
|
||||
同一篇文档可以有多个画板。优先设计多个聚焦画板,而不是把所有信息塞进一张大图。
|
||||
|
||||
## 文档与画板协同流程
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
8. **优先处理步骤三识别出的画板需求**:
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
9. Spawn 内容改写 Agent 定向润色:
|
||||
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
|
||||
- 文字密集且不易读的章节可转为 `<table>`/`<grid>`/`<callout>`,也可以拆段、改列表或保留纯文本
|
||||
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
|
||||
- 本地图片使用 `docs +media-insert` 插入
|
||||
|
||||
|
||||
@@ -10,18 +10,18 @@
|
||||
2. **尊重用户风格**:用户给出样例、语气、结构或已有文档时,优先沿用;没有要求时不强行使用固定开头、固定章节或固定视觉组件
|
||||
3. **适度结构化**:结构化 block 用于降低理解成本,不为了“丰富”而堆叠
|
||||
4. **保持一致但不过度统一**:同类信息可使用相近表达,但允许因内容差异采用不同形式
|
||||
5. **图示服务理解**:流程、架构、对比、风险、路线图、指标趋势等内容在图示明显降低理解成本时,可使用画板表达
|
||||
5. **重要信息画板化**:核心流程、架构、对比、风险、路线图、指标趋势等重要信息优先使用画板表达
|
||||
|
||||
## 二、元素选择指南
|
||||
|
||||
需要图表时,按类型选择插入方式:思维导图/时序图/类图/饼图/甘特图可用 `<whiteboard type="mermaid">` 直接内嵌;其他新图表可启动 SubAgent 插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;只有编辑**已有**画板时才调用 **lark-whiteboard** skill。
|
||||
|
||||
| 场景 | 可选表达方式 |
|
||||
| 场景 | 推荐方案 |
|
||||
|--------------------------------------------|---------------------------------------|
|
||||
| 少数需要视觉提醒的短句,如风险、限制、待确认事项或关键提醒 | 需要视觉提醒时可用 `<callout>`;普通结论、摘要或章节导语优先使用段落、列表、小标题或加粗 |
|
||||
| 方案对比 / 优劣势 / Before vs After | 简短对比可用段落、列表或 `<grid>`;维度较多且需要逐项比较时再考虑 `<table>` 或画板 |
|
||||
| 需要突出的一小段结论 / 摘要 / 注意事项 | `<callout>`;是否使用 emoji 和颜色由文档语气决定 |
|
||||
| 方案对比 / 优劣势 / Before vs After | `<grid>` 2 列分栏、`<table>` 或画板,按复杂度选择 |
|
||||
| 简短低风险对比 | `<grid>` 2 列分栏 |
|
||||
| 需要按行列精确比较或查阅的数据,如指标、清单、字段说明、排期 | 可用 `<table>`;短要点、步骤、摘要或普通说明优先使用段落、列表或小标题 |
|
||||
| 3+ 属性的结构化数据 / 指标表 | `<table>` + 表头背景色 |
|
||||
| 任务清单 / 检查项 | `<checkbox>` |
|
||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
||||
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:
|
||||
- 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
|
||||
- 优先通过重写段落、调整标题、拆分列表或补充小标题提升可读性
|
||||
- 可以通过重写段落、调整标题、拆分列表、补表格/分栏/callout 等方式提升可读性
|
||||
- 富 block 是可选表达手段,不因固定比例而添加;画板类需求只走第 5 步
|
||||
|
||||
### 步骤三:验证(串行)
|
||||
|
||||
@@ -18,7 +18,6 @@ metadata:
|
||||
|
||||
## 快速决策
|
||||
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `need_relation=true` 返回评论位置,其他类型暂不支持,具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。
|
||||
@@ -70,7 +69,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
| 操作 | 需要的 Token | 说明 |
|
||||
|------|-------------|------|
|
||||
| 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `<sheetId>!<cell>`,`slides` 使用 `<slide-block-type>!<xml-id>`;Base 只有记录局部评论,定位为 file_token(base_token) + `--block-id <table-id>!<record-id>!<view-id>` |
|
||||
| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `<sheetId>!<cell>`,`slides` 使用 `<slide-block-type>!<xml-id>`,且都支持最终解析到对应类型的 wiki URL;Drive file 不支持局部评论 |
|
||||
| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL |
|
||||
| 下载文件 | `file_token` | 从文件 URL 中直接提取 |
|
||||
| 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token |
|
||||
@@ -82,15 +81,6 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
- 评论查询、统计、排序、回复限制,先读 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。
|
||||
- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md);其他文档类型暂不支持返回定位字段。
|
||||
- reaction / 表情相关操作先读 [`lark-drive-reactions.md`](references/lark-drive-reactions.md);只有用户明确需要 reaction 信息时才带 `need_reaction=true`。
|
||||
- `drive +add-comment` 的 `--content` 需要传 `reply_elements` JSON 数组字符串,例如 `--content '[{"type":"text","text":"正文"}]'`。
|
||||
- `slides` 评论要求显式传 `--block-id <slide-block-type>!<xml-id>`;CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `<xml-id>` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。
|
||||
- 评论写入内容(添加评论、回复评论、编辑回复)里的文本不能直接出现 `<`、`>`;提交前必须先转义:`<` -> `<`,`>` -> `>`。
|
||||
- 使用 `drive +add-comment` 时,shortcut 会对 `type=text` 的文本元素自动做上述转义兜底;如果直接调用 `drive file.comments create_v2`、`drive file.comment.replys create`、`drive file.comment.replys update`,则需要在请求里自行传入已转义的内容。
|
||||
- Base 记录局部评论使用 `--type bitable` / `--type base` 或 `/base/`、`/bitable/`、wiki Base 链接;`bitable` 和 Base 是同一概念,`bitable` 是内部代号、Base 是产品名,裸 token 推荐传 `bitable`,`base` 仅作为兼容别名兜底。
|
||||
- Base 不支持全局评论,所有评论都挂在记录上;定位信息必须是 file token(base token)+ `--block-id <table-id>!<record-id>!<view-id>`,其中 table/record/view ID 通常分别以 `tbl`/`rec`/`vew` 开头。view_id 只决定被提及时点击通知打开哪个视图,不影响评论挂载点;只要在同一记录上都能看到评论,但必须传,否则通知无法确定跳转视图。ID 可通过 [`lark-base`](../lark-base/SKILL.md) 获取。
|
||||
- 如果 wiki 解析后不是 `doc`/`docx`/`file`/`sheet`/`slides`/`bitable`/`base`,不要用 `+add-comment`。
|
||||
- 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`;docx/sheet/slides 局部评论传 `anchor.block_id`,Base 记录局部评论传 `anchor.block_id`(table_id)、`anchor.base_record_id`、`anchor.base_view_id`。
|
||||
- 直接调用原生 `drive.file.comments.*` / `drive.file.comment.replys.*` 评论 Base 文档时,`file_type` 填 `bitable`,不要填 `base`。
|
||||
|
||||
### 典型错误与解决方案
|
||||
|
||||
@@ -98,7 +88,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
||||
|----------|------|----------|
|
||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides) |
|
||||
|
||||
### 权限能力入口
|
||||
|
||||
@@ -131,7 +121,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| `+sync` | 双向同步本地目录与 Drive 文件夹:拉取 `new_remote`、推送 `new_local`,`modified` 按 `--on-conflict=remote-wins\|local-wins\|keep-both\|ask` 处理;`--quick` 用修改时间近似比较;`--on-duplicate-remote` 支持 `fail` / `newest` / `oldest`;只同步 `type=file`,跳过在线文档和 shortcut,且不会删除两端多余文件。 |
|
||||
| [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 |
|
||||
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides/base(bitable) 添加评论,也支持解析到这些类型的 wiki URL;评论统计、回复和 reaction 细则见 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。 |
|
||||
| [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides 添加评论;评论统计、回复和 reaction 细则见 [`lark-drive-comments-guide.md`](references/lark-drive-comments-guide.md)。 |
|
||||
| [`+export`](references/lark-drive-export.md) | 将 doc/docx/sheet/bitable/slides 导出为本地文件。 |
|
||||
| [`+export-download`](references/lark-drive-export-download.md) | 根据导出产物的 file_token 下载文件。 |
|
||||
| [`+import`](references/lark-drive-import.md) | 将本地文件导入为飞书在线文档、表格、多维表格或幻灯片。 |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
给文档、受支持的 Drive 普通文件、电子表格、飞书幻灯片或 Base 添加评论。未指定位置时创建全文评论,但仅适用于 doc/docx、白名单 Drive file,以及解析为这些类型的 wiki;sheet、slides、Base(bitable) 必须指定 `--block-id`。不同类型的 `--block-id` 格式见下文。支持直接传 docx URL/token、旧版 doc URL(仅全文评论)、Drive file URL/token(**仅支持白名单扩展名,且只支持全文评论**)、sheet URL、slides URL、base/bitable URL,也支持传最终可解析为 doc/docx/file/sheet/slides/base(bitable) 的 wiki URL。
|
||||
给文档、受支持的 Drive 普通文件、电子表格或飞书幻灯片添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments`(`create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL(仅全文评论)、Drive file URL/token(**仅支持白名单扩展名,且只支持全文评论**)、sheet URL、slides URL,也支持传最终可解析为 doc/docx/file/sheet/slides 的 wiki URL。
|
||||
|
||||
## 命令
|
||||
|
||||
@@ -127,18 +127,6 @@ lark-cli drive file.comments create_v2 \
|
||||
--params '{"file_token":"<DOC_TOKEN>"}' \
|
||||
--data '{"file_type":"docx","reply_elements":[{"type":"text","text":"全文评论内容"}]}'
|
||||
|
||||
# Base 记录局部评论;原生 file_type 传 bitable。
|
||||
lark-cli drive +add-comment \
|
||||
--doc "<BASE_TOKEN>" --type bitable \
|
||||
--block-id "<TABLE_ID>!<RECORD_ID>!<VIEW_ID>" \
|
||||
--content '[{"type":"text","text":"Base record-local comment"}]'
|
||||
|
||||
# `base` 也可作为裸 token 类型别名;/base/ 与 /bitable/ URL 都会自动识别为 Base。
|
||||
lark-cli drive +add-comment \
|
||||
--doc "<BASE_TOKEN>" --type base \
|
||||
--block-id "<TABLE_ID>!<RECORD_ID>!<VIEW_ID>" \
|
||||
--content '[{"type":"text","text":"Base alias comment"}]'
|
||||
|
||||
# 预览底层调用链
|
||||
lark-cli drive +add-comment \
|
||||
--doc "https://example.larksuite.com/docx/<DOC_ID>" \
|
||||
@@ -151,11 +139,11 @@ lark-cli drive +add-comment \
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `--doc` | 是 | 文档 URL / token、file / sheet / slides / base / bitable URL,或可解析到 `doc`/`docx`/`file`/`sheet`/`slides`/`base(bitable)` 的 wiki URL |
|
||||
| `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`file`、`sheet`、`slides`、`bitable`、`base`;评论 Base 文档推荐传 `bitable`,`base` 仅作为兼容别名兜底。URL 输入时自动识别,无需传 |
|
||||
| `--doc` | 是 | 文档 URL / token、file / sheet / slides URL,或可解析到 `doc`/`docx`/`file`/`sheet`/`slides` 的 wiki URL |
|
||||
| `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`file`、`sheet`、`slides`。URL 输入时自动识别,无需传 |
|
||||
| `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` |
|
||||
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论(仅适用于 doc/docx、白名单 Drive file,以及解析为这些类型的 wiki;不适用于 sheet、slides、Base / bitable) |
|
||||
| `--block-id` | 局部评论时必填 | 目标块 ID,可通过 `docs +fetch --api-version v2 --detail with-ids` 获取;sheet 用 `<sheetId>!<cell>`,slides 用 `<slide-block-type>!<xml-id>`,Base 用 `<table-id>!<record-id>!<view-id>` |
|
||||
| `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论(不适用于 sheet) |
|
||||
| `--block-id` | 局部评论时必填 | 目标块 ID,可通过 `docs +fetch --api-version v2 --detail with-ids` 获取。**Sheet 评论**:格式为 `<sheetId>!<cell>`(如 `a281f9!D6`) |
|
||||
|
||||
## 行为说明
|
||||
|
||||
@@ -164,11 +152,10 @@ lark-cli drive +add-comment \
|
||||
- 未传 `--block-id` 时,shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终可解析为 `doc`/`docx`/`file` 的 wiki URL。
|
||||
- **Drive file 评论**:仅支持白名单扩展名的普通文件。当前支持:`.md`、`.txt`、`.json`、`.csv`、`.go`、`.js`、`.py`、`.pptx`、`.png`、`.jpg`、`.jpeg`、`.zip`、`.mp3`、`.mp4`。
|
||||
- **Drive file 暂不支持**:`.pdf`、`.docx`、`.xlsx` 等未在白名单内的普通文件会被 CLI 拒绝,并提示“当前还不支持这种类型的评论”。这些类型虽然可能接受 OpenAPI 请求,但在页面评论展示上存在问题。
|
||||
- **Drive file 只支持全文评论**:file 目标不支持局部评论,不允许传 `--block-id` 或 `--selection-with-ellipsis`。
|
||||
- 传 `--block-id` 时,shortcut 创建**局部评论(划词评论)**;该模式支持 `docx`、`sheet`、`slides`、Base / bitable,以及最终可解析为这些类型的 wiki URL。
|
||||
- **Drive file 只支持全文评论**:file 目标不支持局部评论,不允许传 `--block-id` 或 `--selection-with-ellipsis`。由于当前 OpenAPI 要求 file 评论传入非空 `anchor.block_id`,CLI 会固定传占位值 `test`,UI 上仍表现为文件全文评论。
|
||||
- 传 `--block-id` 时,shortcut 创建**局部评论(划词评论)**;该模式支持 `docx`、`sheet`、`slides`,以及最终可解析为这些类型的 wiki URL。
|
||||
- **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "<sheetId>!<cell>"` 指定单元格(如 `a281f9!D6`);sheet 没有全文评论,`--full-comment` 不可用。
|
||||
- **Slide 评论**:当 `--doc` 为 slides URL、`--type slides`,或 wiki 解析为 slides 时,必须传 `--block-id "<SLIDE_BLOCK_TYPE>!<XML_ELEMENT_ID>"`。此时 `--full-comment` 和 `--selection-with-ellipsis` 不可用。
|
||||
- **Base 记录局部评论**:Base 不支持全局评论,所有评论都挂在记录上;裸 token 可传 `--type bitable` 或 `--type base`,推荐 `bitable`。定位信息必须是 file token(base token)+ `--block-id "<table-id>!<record-id>!<view-id>"`,其中 table/record/view ID 通常分别以 `tbl`/`rec`/`vew` 开头;view_id 只决定被提及时点击通知打开哪个视图,不影响评论挂载点,但必须传。ID 获取参考 [`lark-base`](../../lark-base/SKILL.md)。
|
||||
- **Slide 评论**:当 `--doc` 为 slides URL、`--type slides`,或 wiki 解析为 slides 时,必须传 `--block-id "<SLIDE_BLOCK_TYPE>!<XML_ELEMENT_ID>"`。CLI 会将其拆分映射到 `anchor.block_id` / `anchor.slide_block_type`。此时 `--full-comment` 和 `--selection-with-ellipsis` 不可用。
|
||||
- **Slide 参数映射示例**:`--block-id` 由 PPT XML 元素类型和元素 `id` 组成。例如:
|
||||
- `<slide id="pkk">` 对应 `--block-id slide!pkk`,表示给整页评论。
|
||||
- `<img id="bPk" ... />` 对应 `--block-id img!bPk`,表示给图片元素评论。
|
||||
@@ -178,11 +165,13 @@ lark-cli drive +add-comment \
|
||||
- `type=text` 的评论文本不能直接包含 `<`、`>`;应优先传 `<`、`>`。shortcut 在发送前也会自动将 `<`、`>` 转义为 `<`、`>` 作为兜底。
|
||||
- **所有 `type=text` 元素的字符总和 ≤ 10000**(按字符算,中英文 / 符号一视同仁)。超过会被 shortcut 在发送前拒绝,并指出累计超长的元素。**拆成多个 text element 不能绕过这个上限**——上限是总额,不是每元素。需要更长内容就缩短或拆成多条评论。
|
||||
- 长度限制只对 `type=text` 生效,`mention_user` / `link` 不计入。
|
||||
- 写入评论前会自动生成符合 OpenAPI 定义的请求体;shortcut 用户只需要传 `--doc`、`--content`,局部评论再传对应格式的 `--block-id`。
|
||||
- 写入评论前会自动生成符合 OpenAPI 定义的请求体:
|
||||
- 统一接口:`POST /new_comments`
|
||||
- 统一字段:`file_type` + `reply_elements`
|
||||
- 全文评论:省略 `anchor`
|
||||
- 局部评论:传入 `anchor.block_id`
|
||||
- `--dry-run` 仅预览调用链和请求体,不会实际写入。
|
||||
- 如果需要更底层的控制,仍可改用 `lark-cli schema drive.file.comments.create_v2` + `lark-cli drive file.comments create_v2`。
|
||||
- 直接调用原生 `drive.file.comments.create_v2` 时,全文评论省略 `anchor`;docx/sheet/slides 局部评论传 `anchor.block_id`,Base 记录局部评论传 `anchor.block_id`(table_id)、`anchor.base_record_id`、`anchor.base_view_id`。
|
||||
- 直接调用原生 `drive.file.comments.*` / `drive.file.comment.replys.*` 评论 Base 文档时,`file_type` 填 `bitable`,不要填 `base`。
|
||||
|
||||
> [!CAUTION]
|
||||
> 这是**写入操作** —— 执行前必须确认用户意图。
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
# 权限治理 Command Patterns
|
||||
|
||||
本文只提供 `permission_governance` workflow 的具体 `lark-cli` 命令样例。只有进入对应 state 且需要拼装命令时才读取本文;命令可用范围仍以 [`lark-drive-workflow-permission-governance.md`](lark-drive-workflow-permission-governance.md) 的 `Command Map` 为准。
|
||||
|
||||
## 目录
|
||||
|
||||
- `目标解析`
|
||||
- `目标发现`
|
||||
- `事实读取`
|
||||
- `写前确认与执行`
|
||||
|
||||
## 目标解析
|
||||
|
||||
```bash
|
||||
lark-cli drive +inspect --url '<url>' --as user --format json
|
||||
```
|
||||
|
||||
`/wiki/space/<space_id>` URL 是 Wiki space 范围,不要用 `drive +inspect` 当作单文档解析;直接提取 `space_id` 后进入 `DISCOVER_TARGETS`。
|
||||
|
||||
## 目标发现
|
||||
|
||||
发现 Wiki space / node 下目标:
|
||||
|
||||
```bash
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --parent-node-token '<node_token>' --page-size 50 \
|
||||
--page-all --page-limit 0 \
|
||||
--as user --format json
|
||||
|
||||
lark-cli wiki +node-list \
|
||||
--space-id '<space_id>' --page-token '<PAGE_TOKEN>' --page-size 50 \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
解析返回时使用 `data.nodes`,不要读取顶层 `items`。`--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。
|
||||
|
||||
发现 Drive folder 下目标:
|
||||
|
||||
```bash
|
||||
lark-cli drive files list \
|
||||
--params '{"folder_token":"<folder_token>","page_size":200}' \
|
||||
--as user --format json
|
||||
|
||||
lark-cli drive files list \
|
||||
--params '{"folder_token":"<folder_token>","page_size":200,"page_token":"<PAGE_TOKEN>"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
## 事实读取
|
||||
|
||||
读取 metadata:
|
||||
|
||||
```bash
|
||||
lark-cli drive metas batch_query \
|
||||
--data '{"request_docs":[{"doc_token":"<token>","doc_type":"<type>"}],"with_url":true}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
读取 public permission:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.public get \
|
||||
--params '{"token":"<token>","type":"<type>"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
按需读取访问统计:
|
||||
|
||||
```bash
|
||||
lark-cli drive file.statistics get \
|
||||
--params '{"file_token":"<token>","file_type":"<type>"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
按需读取最近访问记录:
|
||||
|
||||
```bash
|
||||
lark-cli drive file.view_records list \
|
||||
--params '{"file_token":"<token>","file_type":"<type>","page_size":50}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
## 写前确认与执行
|
||||
|
||||
patch 前检查 manage-public permission:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.members auth \
|
||||
--params '{"token":"<token>","type":"<type>","action":"manage_public"}' \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
patch 前读取当前 schema:
|
||||
|
||||
```bash
|
||||
lark-cli schema drive.permission.public.patch --format json
|
||||
```
|
||||
|
||||
只 patch 当前 schema 支持的字段;对 Wiki 目标,必须省略 schema 明确标注为 Wiki 不支持的字段。
|
||||
|
||||
显式确认后 patch public permission:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.public patch \
|
||||
--params '{"token":"<token>","type":"<type>"}' \
|
||||
--data '{"link_share_entity":"closed","external_access":false}' \
|
||||
--as user --yes --format json
|
||||
```
|
||||
|
||||
显式确认后申请访问权限:
|
||||
|
||||
```bash
|
||||
lark-cli drive +apply-permission \
|
||||
--token '<url>' \
|
||||
--perm view --remark '<reason>' --as user --format json
|
||||
|
||||
lark-cli drive +apply-permission \
|
||||
--token '<bare-token>' --type '<type>' \
|
||||
--perm view --remark '<reason>' --as user --format json
|
||||
```
|
||||
|
||||
owner 转移前读取当前 schema:
|
||||
|
||||
```bash
|
||||
lark-cli schema drive.permission.members.transfer_owner --format json
|
||||
```
|
||||
|
||||
显式确认后转移 owner:
|
||||
|
||||
```bash
|
||||
lark-cli drive permission.members transfer_owner \
|
||||
--params '{"token":"<token>","type":"<type>","need_notification":true,"remove_old_owner":false,"old_owner_perm":"full_access","stay_put":true}' \
|
||||
--data '{"member_id":"<new_owner_open_id>","member_type":"openid"}' \
|
||||
--as user --yes --format json
|
||||
```
|
||||
|
||||
`member_type` 只能使用当前 schema 支持的值:`email`、`openid`、`userid`、`appid`。如果用户只给姓名,必须先解析为明确身份或要求用户补充;不要猜测 `member_id`。批量 owner 转移必须逐个目标顺序执行。
|
||||
|
||||
secure label 写前枚举可用标签:
|
||||
|
||||
```bash
|
||||
lark-cli drive +secure-label-list \
|
||||
--page-size 10 --lang zh \
|
||||
--as user --format json
|
||||
|
||||
lark-cli drive +secure-label-list \
|
||||
--page-size 10 --page-token '<PAGE_TOKEN>' --lang zh \
|
||||
--as user --format json
|
||||
```
|
||||
|
||||
当用户给出的是标签名称、密级文案或不确定的 label ID 时,必须先枚举并解析为 `label-id`;写入确认里展示目标标签名称和 ID。找不到唯一标签时,停止并让用户选择,不要猜测。
|
||||
|
||||
显式确认后更新 secure label:
|
||||
|
||||
```bash
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<url>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
|
||||
lark-cli drive +secure-label-update \
|
||||
--token '<bare-token>' --type '<type>' \
|
||||
--label-id '<label-id>' --as user --format json
|
||||
```
|
||||
@@ -1,424 +0,0 @@
|
||||
# 权限治理输出模板
|
||||
|
||||
本文只提供 `permission_governance` workflow 的用户可见输出模板。默认先给简短摘要;只有用户要求完整表格、需要写入确认,或结果大到需要结构化展示时才读取本文。
|
||||
|
||||
## 目录
|
||||
|
||||
- `输出策略`
|
||||
- `Semantic Rendering`
|
||||
- `定位与治理动作`
|
||||
- `单目标公开性判断`
|
||||
- `多目标明确列表诊断`
|
||||
- `审计摘要`
|
||||
- `容器安全诊断报告摘要`
|
||||
- `可操作风险清单`
|
||||
- `治理选择交互`
|
||||
- `权限设置清单`
|
||||
- `访问复核清单`
|
||||
- `整改 dry-run`
|
||||
- `批量权限申请确认`
|
||||
- `owner 转移确认`
|
||||
- `确认请求`
|
||||
- `最终摘要`
|
||||
|
||||
## 输出策略
|
||||
|
||||
- 单目标默认输出审计摘要。
|
||||
- 多目标明确列表默认输出逐目标诊断摘要;不要因为目标数大于 1 就套用容器递归发现报告。
|
||||
- 用户可见结论默认跟随用户当前语言。用户用中文提问时输出中文,用户用英文提问时输出英文;混合语言时跟随主要语言。
|
||||
- 单目标公开性判断默认输出业务表达,不直接展示 `link_share_entity`、`external_access_entity`、`external_access` 等底层字段名;只有用户要求 raw evidence、排障,或完整清单 / artifact 场景才展示底层字段。
|
||||
- 中文用户可见输出中,`permission_public` / `public permission` 默认译为“文档公共访问和协作权限设置”;可在摘要里简称“公共访问与协作设置”。它在官方语义中包含链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论;具体可判断字段以当前 CLI schema 和实际响应为准。只有命令名、schema 字段、raw evidence、排障信息和完整 artifact 字段名保留英文原文。
|
||||
- 容器目标默认输出安全诊断报告摘要:一句话结论、覆盖情况、风险分级、优先处理对象、建议下一步和剩余限制。
|
||||
- 容器目标不要把风险按数量机械排序;外部公开、允许对外分享、缺失密级标签优先于复制 / 下载 / 评论这类依赖策略的候选项。
|
||||
- 用户没有提供明确 policy 时,使用“候选风险 / 待复核 / 待策略确认”,不要写“违规 / 已泄露 / 已外部访问”。
|
||||
- 容器安全诊断里不要把 `external_access=true` / `external_access_entity=open` 简写成“高风险”或“外部泄露”;用户可见说法应为“允许对外分享,需 owner 复核;这不等于已经存在外部协作者”。
|
||||
- 风险对象展示按规模渐进披露:1-10 个全部展示;11-30 个展示全部高优先级待复核对象,中 / 低优先级只做分组摘要;31-100 个按高优先级待复核分组展示 Top 5 和数量;100+ 个只展示分组统计和 Top 样例。
|
||||
- 当摘要未展示全部风险对象时,必须明确“完整清单包含 <count> 条”,并提供生成 Markdown / CSV / 飞书文档风险清单或整改 dry-run 的下一步。
|
||||
- 只要发现需要处理的对象,最终回复必须给出可执行下一步 CTA。不能因为默认只读,就只报告风险后结束。
|
||||
- 完整风险清单是后续治理选择的输入;Markdown / CSV / 飞书文档报告必须使用同一套字段和稳定 `risk_id`。
|
||||
- 写入前必须使用确认模板;权限申请、文档公共访问和协作权限设置修改、owner 转移、密级标签更新分别确认。
|
||||
- 最终回复必须包含已完成事项、验证结果和剩余限制;异步权限申请审批不能表述为已完成授权。
|
||||
|
||||
## Semantic Rendering
|
||||
|
||||
面向用户的主结论优先渲染 `per_target_permission_assessment` 中的语义状态,并使用用户当前语言;底层字段名只在 raw evidence、排障或完整清单中保留。下表给出字段值到业务表达的标准映射;其他语言应表达同等业务含义。
|
||||
|
||||
字段来源边界:下表同时覆盖官方 OpenAPI 语义和当前 / 未来 CLI schema。只有实际响应或当前 schema 返回的字段和值,才可渲染为确定状态;当前 installed CLI 未返回的字段(例如 `copy_entity`、`manage_collaborator_entity`、`external_access_entity`)或未出现的枚举值,只能在 raw response / schema 实际出现时使用,缺失时必须按 unknown / unsupported 处理,不要臆造。
|
||||
|
||||
| Raw field / value | Semantic State | 中文说法 | English phrasing |
|
||||
|-------------------|----------------|----------|------------------|
|
||||
| `link_share_entity=anyone_readable` | `link_access=public_readable` | 互联网上获得链接的任何人可阅读 | Anyone on the internet with the link can read |
|
||||
| `link_share_entity=anyone_editable` | `link_access=public_editable` | 互联网上获得链接的任何人可编辑 | Anyone on the internet with the link can edit |
|
||||
| `link_share_entity=partner_tenant_readable` | `link_access=partner_readable` | 关联组织内知道链接可读 | People in partner tenants with the link can read |
|
||||
| `link_share_entity=partner_tenant_editable` | `link_access=partner_editable` | 关联组织内知道链接可编辑 | People in partner tenants with the link can edit |
|
||||
| `link_share_entity=tenant_readable` | `link_access=tenant_readable` | 公司内知道链接可读 | People in the tenant with the link can read |
|
||||
| `link_share_entity=tenant_editable` | `link_access=tenant_editable` | 公司内知道链接可编辑 | People in the tenant with the link can edit |
|
||||
| link sharing empty / disabled | `link_access=closed` | 未开启链接分享 | Link sharing is disabled |
|
||||
| `external_access_entity=open` or `external_access=true` | `external_sharing=open` | 允许分享到组织外;不等于已经存在外部协作者 | External sharing is open; this does not mean external collaborators already exist |
|
||||
| `external_access_entity=allow_share_partner_tenant` | `external_sharing=partner_only` | 仅允许分享到关联组织 | Sharing is allowed only with partner tenants |
|
||||
| `external_access_entity=closed` or `external_access=false` | `external_sharing=closed` | 当前不允许分享到组织外 | External sharing is disabled |
|
||||
| `invite_external=true` | `external_invitation=enabled` | 当前允许邀请外部用户 | Inviting external users is enabled |
|
||||
| `invite_external=false` | `external_invitation=disabled` | 当前不允许邀请外部用户 | Inviting external users is disabled |
|
||||
| `share_entity=anyone` | `collaborator_org_scope=all_viewers_or_editors` | 所有可阅读或可编辑者可查看、添加、移除协作者 | All viewers or editors can view, add, and remove collaborators |
|
||||
| `share_entity=same_tenant` | `collaborator_org_scope=tenant_viewers_or_editors` | 组织内可阅读或可编辑者可查看、添加、移除协作者 | Tenant viewers or editors can view, add, and remove collaborators |
|
||||
| `manage_collaborator_entity=collaborator_can_view` | `collaborator_permission_scope=viewer` | 拥有可阅读权限的协作者可查看、添加、移除协作者 | Collaborators with view permission can view, add, and remove collaborators |
|
||||
| `manage_collaborator_entity=collaborator_can_edit` | `collaborator_permission_scope=editor` | 拥有可编辑权限的协作者可查看、添加、移除协作者 | Collaborators with edit permission can view, add, and remove collaborators |
|
||||
| `manage_collaborator_entity=collaborator_full_access` | `collaborator_permission_scope=full_access` | 拥有可管理权限的协作者可查看、添加、移除协作者 | Collaborators with full-access permission can view, add, and remove collaborators |
|
||||
| `copy_entity=anyone_can_view` | `copy_scope=viewer` | 拥有可阅读权限的用户可复制内容 | Users with view permission can copy content |
|
||||
| `copy_entity=anyone_can_edit` | `copy_scope=editor` | 拥有可编辑权限的用户可复制内容 | Users with edit permission can copy content |
|
||||
| `copy_entity=only_full_access` | `copy_scope=full_access` | 仅拥有可管理权限的协作者可复制内容 | Only collaborators with full-access permission can copy content |
|
||||
| `security_entity=anyone_can_view` | `security_scope=viewer` | 拥有可阅读权限的用户可创建副本、打印、下载 | Users with view permission can create copies, print, and download |
|
||||
| `security_entity=anyone_can_edit` | `security_scope=editor` | 拥有可编辑权限的用户可创建副本、打印、下载 | Users with edit permission can create copies, print, and download |
|
||||
| `security_entity=only_full_access` | `security_scope=full_access` | 仅拥有可管理权限的用户可创建副本、打印、下载 | Only users with full-access permission can create copies, print, and download |
|
||||
| `comment_entity=anyone_can_view` | `comment_scope=viewer` | 拥有可阅读权限的用户可评论 | Users with view permission can comment |
|
||||
| `comment_entity=anyone_can_edit` | `comment_scope=editor` | 拥有可编辑权限的用户可评论 | Users with edit permission can comment |
|
||||
| `lock_switch=true` | `lock_state=locked_not_inheriting` | 已限制权限,不再继承父级页面权限 | The node is locked and no longer inherits parent-page permissions |
|
||||
| `lock_switch=false` | `lock_state=not_locked_or_inheriting` | 未限制权限,可能继承父级页面权限 | The node is not locked and may inherit parent-page permissions |
|
||||
| field absent / unsupported | `<state>=unknown` | 当前 schema 未返回,无法判断 | The current schema did not return this field, so it is unknown |
|
||||
| `check_scope=current_public_permission_only` | `check_scope=current_public_permission_only` | 本次判断的是当前文档公共访问和协作权限设置,不是协作者名单或历史权限变更审计 | This check covers current public access and collaboration settings, not collaborator-list or historical permission-change auditing |
|
||||
| `sec_label_name` missing | `sec_label=missing` | 缺少密级标签 | Security label is missing |
|
||||
|
||||
## 定位与治理动作
|
||||
|
||||
风险对象必须能让用户直接定位和处理:
|
||||
|
||||
- 摘要中的每个优先处理对象必须包含 `risk_id`、`path/title`、`URL`、`type`、owner、sec_label、风险原因、关键证据和建议动作。
|
||||
- 完整清单、访问复核清单、整改 dry-run 和写入确认都必须包含 URL。缺少 URL 时,展示 token / node_token,并说明 URL 未能获取。
|
||||
- 同名文档、shortcut 或副本必须用 path + URL 区分;不要只输出 title。
|
||||
- 完整风险清单中的每条记录必须有稳定 `risk_id`,格式为 `PG-001`、`PG-002`。`risk_id` 在同一次诊断和后续 dry-run / 确认 / 验证中保持不变。
|
||||
- 即使摘要只展示 Top 样例,也必须给样例分配稳定 `risk_id`;不能输出无法选择的标题列表。
|
||||
- 建议动作必须和风险类型绑定:互联网公开链接优先建议关闭链接分享或收紧为组织内;允许对外分享优先建议 owner 复核或关闭对外分享;缺少密级标签优先建议补齐密级;复制 / 下载 / 评论范围只在用户 policy 明确时建议收紧。
|
||||
- 写入动作只能作为下一步选项或确认请求出现。不要在诊断摘要里暗示已经执行缩权。
|
||||
|
||||
## 单目标公开性判断
|
||||
|
||||
当 `intent=public_exposure_check` 且 `target_scope=single_resource` 时,使用此模板。默认渲染 `target_count=1` 的 `per_target_permission_assessment`,跟随用户当前语言,不直接展示底层字段名;用户要求 raw evidence 时,再追加字段证据。
|
||||
|
||||
中文模板:
|
||||
|
||||
```text
|
||||
结论:<不是对外公开 / 存在互联网公开链接 / 允许对外分享>。
|
||||
|
||||
目标:<title>
|
||||
URL:<url-or-token-if-url-unavailable>
|
||||
类型:<type>
|
||||
|
||||
当前链接访问范围:<render link_access>
|
||||
对外分享:<render external_sharing>
|
||||
外部邀请:<render external_invitation or omit if unknown because field is absent>
|
||||
协作者管理(组织维度):<render collaborator_org_scope>
|
||||
协作者管理(权限维度):<render collaborator_permission_scope or omit if unknown because field is absent>
|
||||
复制内容:<render copy_scope or omit if unknown because field is absent>
|
||||
创建副本 / 打印 / 下载:<render security_scope>
|
||||
评论:<render comment_scope>
|
||||
Wiki 继承限制:<render lock_state or omit if unknown because field is absent>
|
||||
|
||||
检查边界:<render check_scope>
|
||||
```
|
||||
|
||||
English template:
|
||||
|
||||
```text
|
||||
Conclusion: <Not publicly accessible on the internet / A public internet link is enabled / External sharing is enabled>.
|
||||
|
||||
Target: <title>
|
||||
URL: <url-or-token-if-url-unavailable>
|
||||
Type: <type>
|
||||
|
||||
Current link access: <render link_access>
|
||||
External sharing: <render external_sharing>
|
||||
External invitations: <render external_invitation or omit if unknown because field is absent>
|
||||
Collaborator management by tenant: <render collaborator_org_scope>
|
||||
Collaborator management by permission: <render collaborator_permission_scope or omit if unknown because field is absent>
|
||||
Copy content: <render copy_scope or omit if unknown because field is absent>
|
||||
Create copies / print / download: <render security_scope>
|
||||
Comments: <render comment_scope>
|
||||
Wiki inheritance lock: <render lock_state or omit if unknown because field is absent>
|
||||
|
||||
Check boundary: <render check_scope>
|
||||
```
|
||||
|
||||
Raw evidence, only when requested:
|
||||
|
||||
```text
|
||||
Evidence fields:
|
||||
- link_share_entity=<value>
|
||||
- external_access_entity=<value>
|
||||
- external_access=<value>
|
||||
- invite_external=<value>
|
||||
- share_entity=<value>
|
||||
- manage_collaborator_entity=<value>
|
||||
- copy_entity=<value>
|
||||
- security_entity=<value>
|
||||
- comment_entity=<value>
|
||||
- lock_switch=<value>
|
||||
```
|
||||
|
||||
## 多目标明确列表诊断
|
||||
|
||||
当 `target_scope=explicit_list` 时,使用此模板。该场景不执行容器递归发现;对用户提供的每个 URL / token 逐个生成 `per_target_permission_assessment`,再按风险分组聚合。权限语义和单目标、容器诊断完全复用,不新增判断模型。
|
||||
|
||||
```text
|
||||
已完成只读权限诊断,没有做任何权限修改。
|
||||
|
||||
一句话结论:<N> 个目标中,<risk_count> 个存在待复核权限风险;<internet_public_count> 个存在互联网公开链接候选,<external_access_count> 个允许对外分享,<unknown_count> 个无法完整判断。
|
||||
|
||||
覆盖情况:
|
||||
- 用户提供目标:<input_target_count>;成功解析:<resolved_count>
|
||||
- 成功读取文档公共访问和协作权限设置:<permission_checked_count>;读取失败 / 不支持 / 无权限:<failed_or_unsupported_count>
|
||||
|
||||
逐目标结果(1-10 个目标默认全部展示;超过 10 个时按 `摘要清单展开规则` 展示,并提示生成完整风险清单):
|
||||
|
||||
- <risk_id-or-item_id> <path-or-title> (<type>)
|
||||
URL: <url-or-token-if-url-unavailable>
|
||||
结论:<not_public / public_link_enabled / external_sharing_enabled / policy_review / unknown>
|
||||
关键权限:<render link_access>; <render external_sharing>; <render security_scope>; <render comment_scope>
|
||||
密级:<sec_label_name-or-missing-or-unknown>
|
||||
待复核原因:<risk reason or none>
|
||||
建议动作:<recommended action or no action>
|
||||
|
||||
分组摘要:
|
||||
- 互联网公开链接候选:<count>;允许对外分享:<count>;公司内链接可访问 / 可编辑:<count>
|
||||
- 复制 / 下载 / 打印 / 评论待策略确认:<count>;无法判断:<count and reason summary>
|
||||
|
||||
建议下一步:
|
||||
- 处理明确的 <risk_id>,先生成只读 dry-run。
|
||||
- 生成完整风险清单 artifact,后续可按 `risk_id`、风险分组、URL 或 `selected=true` 选择治理范围;只看权限设置时改用 `权限设置清单`。
|
||||
```
|
||||
|
||||
## 摘要清单展开规则
|
||||
|
||||
容器安全诊断的摘要必须兼顾可读性和可治理性。不要用固定 Top N 代替可处理清单。
|
||||
|
||||
| 风险对象数 | 摘要默认展示 | 必须提供的下一步 |
|
||||
|------------|--------------|------------------|
|
||||
| `0` | 只展示覆盖情况、未覆盖能力和剩余限制 | 如需更细审计,可生成权限设置清单 |
|
||||
| `1-10` | 展示全部风险对象 | 可直接按 `risk_id` 生成 dry-run 或写入确认 |
|
||||
| `11-30` | 展示全部高优先级待复核对象;中 / 低优先级做分组摘要 | 生成完整风险清单 artifact,或按风险分组生成 dry-run |
|
||||
| `31-100` | 每个高优先级待复核分组展示 Top 5,附未展示数量 | 生成 Markdown / CSV / 飞书文档完整风险清单 |
|
||||
| `100+` | 只展示分组统计、Top 样例和覆盖限制,不内联长表 | 强烈建议生成结构化风险清单后再选择治理范围 |
|
||||
|
||||
高优先级待复核对象包括:互联网公开链接、允许对外分享、允许对外分享且缺少 / 低于 policy 密级标签、公司内可编辑链接。协作者管理范围较宽默认归入中优先级待复核;只有用户 policy 明确要求严格协作者管理时才提升优先级。复制 / 下载 / 打印、评论范围在用户未提供明确 policy 时归入“待策略确认”,不要挤占高优先级清单。
|
||||
|
||||
摘要中的每个待复核对象必须包含 `risk_id`、path/title、URL、type、owner、sec_label、风险原因、关键证据和建议动作。对同一底层文档的多个 Wiki 入口或 shortcut,必须用 URL 区分;如果建议合并治理,在建议动作里说明它们指向同一底层对象。
|
||||
|
||||
## 审计摘要
|
||||
|
||||
```text
|
||||
目标:<title> (<type>)
|
||||
URL:<url-or-token-if-url-unavailable>
|
||||
结论:<合规 / 待确认风险 / 无法完整判断>
|
||||
证据:
|
||||
- link_share_entity=<value>
|
||||
- external_access_entity=<value>
|
||||
- external_access=<value>
|
||||
- invite_external=<value>
|
||||
- share_entity=<value>
|
||||
- manage_collaborator_entity=<value>
|
||||
- copy_entity=<value>
|
||||
- security_entity=<value>
|
||||
- comment_entity=<value>
|
||||
- lock_switch=<value>
|
||||
- sec_label_name=<value-or-missing>
|
||||
限制:<unsupported_checks or none>
|
||||
建议动作:<read-only next step or proposed remediation>
|
||||
```
|
||||
|
||||
## 容器安全诊断报告摘要
|
||||
|
||||
```text
|
||||
已完成只读安全诊断,没有做任何权限修改。
|
||||
|
||||
一句话结论:<未发现互联网公开链接 / 存在互联网公开链接候选风险>;<external_access_count> 个文档允许对外分享,<missing_label_count> 个文档缺少密级标签。建议优先复核 <top_priority_group_or_paths>。
|
||||
|
||||
覆盖情况:
|
||||
- 当前身份可见目标:<visible_count>
|
||||
- 已成功检查文档公共访问和协作权限设置:<permission_checked_count>
|
||||
- 读取失败 / 已删除 / 无权限:<failed_count>
|
||||
- 未覆盖能力:<collaborator_list / inheritance / audit_log / view_records / none>
|
||||
|
||||
风险分级:
|
||||
- 高优先级待复核:<internet_public_count> 个互联网公开链接候选;<external_access_count> 个允许对外分享;其中 <external_without_label_count> 个同时缺少密级标签。
|
||||
- 中优先级待复核:<tenant_link_count> 个公司内知道链接可访问 / 可编辑;<wide_share_count> 个协作者管理范围较宽。
|
||||
- 待策略确认:<security_count> 个复制 / 下载 / 打印范围待复核;<comment_count> 个评论范围待复核。
|
||||
- 无法判断:<unsupported_or_unverified_summary>。
|
||||
|
||||
分级含义:
|
||||
- 互联网公开链接:获得链接的任何人可能访问,最高优先级。
|
||||
- 允许对外分享:外部分享能力已开启,需 owner 复核;不等于已经存在外部协作者。
|
||||
- 公司内链接可访问:不是对外公开,但组织内扩散范围较宽。
|
||||
- 复制 / 下载 / 打印 / 评论:是否需要收紧取决于业务 policy 和文档密级。
|
||||
|
||||
高优先级待复核清单:
|
||||
> 按 `摘要清单展开规则` 展示。每个对象必须包含 `risk_id` 和 URL;缺少 URL 时展示 token / node_token 和原因。若没有高优先级对象,只展示中优先级或待策略确认分组摘要。
|
||||
|
||||
- <risk_id> <path-or-title> (<type>)
|
||||
URL: <url-or-token-if-url-unavailable>
|
||||
Owner: <owner-or-unknown>
|
||||
密级:<sec_label_name-or-missing-or-unknown>
|
||||
待复核原因:<why high priority>
|
||||
证据:<short user-language evidence, e.g. 对外分享=已开启;链接分享=未开启互联网公开链接>
|
||||
建议动作:<recommended action>
|
||||
|
||||
未完全展开:
|
||||
- 完整风险清单包含 <risk_manifest_count> 条;本摘要已展示 <shown_count> 条,未展示 <hidden_count> 条。
|
||||
- 未展示分组:<risk_group=count summary or none>
|
||||
|
||||
建议下一步:
|
||||
- 生成完整风险清单 artifact,包含 `risk_id`、URL、owner、密级、证据字段、建议动作和 `selected` 列。
|
||||
- 基于 risk_id、风险分组、owner、路径、URL 或 artifact 中 `selected=true` 的行生成只读整改 dry-run。
|
||||
- 只针对最高优先级目标进入写入确认流程,例如关闭互联网公开链接或收紧对外分享;写入前仍需二次确认。
|
||||
- 按 owner / 密级生成复核清单。
|
||||
- 继续读取访问记录,判断低活跃高暴露。
|
||||
|
||||
剩余限制:
|
||||
- <do not claim collaborator-list verification if unsupported>
|
||||
- <external_access_entity=open or external_access=true only means sharing outside is allowed, not that external collaborators exist>
|
||||
- <missing view_records / DLP / AI index status / audit log limitations>
|
||||
```
|
||||
|
||||
## 可操作风险清单
|
||||
|
||||
完整风险清单用于让用户选择后续治理范围。Markdown / CSV / 飞书文档报告都必须包含以下字段;如果某种格式无法完整展示嵌套证据,使用短文本摘要,保留 `risk_id` 和 URL。
|
||||
|
||||
```text
|
||||
范围:<explicit_list / wiki_space / wiki_node / drive_folder> <name-or-id>
|
||||
生成时间:<timestamp>
|
||||
用途:用户可按 risk_id、priority、risk_group、owner、path、URL 或 selected=true 选择治理对象。
|
||||
|
||||
| risk_id | priority | Path | URL | Type | Owner | sec_label | risk_group | evidence | recommended_action | current_setting | target_setting | selected | decision | status | skip_reason |
|
||||
|---------|----------|------|-----|------|-------|-----------|------------|----------|--------------------|-----------------|----------------|----------|----------|--------|-------------|
|
||||
| PG-001 | P1 | <path> | <url-or-token> | <type> | <owner-or-unknown> | <sec-label-or-missing> | <risk_group> | <short evidence> | <recommended-action> | <field=value> | <field=value-or-owner-review> | false | undecided | pending | <none-or-reason> |
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
- `risk_id` 按 priority、risk_group、normalized path、URL、canonical token / node_token 稳定排序生成;URL 缺失时必须使用 token / node_token 作为 tie-breaker。同名、同路径、shortcut 或多个 Wiki 入口不能只靠 path 生成编号;同一次诊断中不得重复。
|
||||
- `priority` 使用 `P0`、`P1`、`P2`、`PolicyReview`、`Unknown`;面向用户展示时可译为“最高优先级 / 高优先级待复核 / 中优先级待复核 / 待策略确认 / 无法判断”。
|
||||
- `selected` 默认 `false`;用户可在 CSV / 飞书文档表格中改为 `true`,或在聊天中直接说 “处理 PG-001、PG-003”。
|
||||
- `decision` 表示用户决策:`undecided`、`keep`、`dry_run`、`confirm_write`、`skip`。
|
||||
- `status` 表示执行状态:`pending`、`dry_run_ready`、`confirmed`、`executed`、`verified`、`failed`、`skipped`。
|
||||
- `target_setting` 是建议目标状态,不代表已执行;没有明确 policy 时只能写 owner review / policy review。
|
||||
|
||||
## 治理选择交互
|
||||
|
||||
用户基于完整风险清单继续治理时,Agent 必须先解析选择范围,再生成只读 dry-run:
|
||||
|
||||
```text
|
||||
可接受的用户选择:
|
||||
- 处理 PG-001、PG-003、PG-008,把互联网公开链接关闭。
|
||||
- 先处理所有 risk_group=internet_public_link,不处理 external_access_only。
|
||||
- 把 CSV / 飞书文档里 selected=true 的行生成整改 dry-run。
|
||||
- PG-003 先跳过,只处理 PG-001。
|
||||
|
||||
Agent 必须回复:
|
||||
- 已选择对象数:<count>
|
||||
- 选择来源:<risk_id list / risk_group / selected=true / URL / path>
|
||||
- 将执行的下一步:生成 dry-run;不执行写入
|
||||
- 需要跳过或重新确认的对象:<missing risk_id / unsupported / changed_since_report / no manage_public>
|
||||
```
|
||||
|
||||
如果用户选择来自旧报告或外部 artifact,生成 dry-run 前必须对所选目标重新读取当前权限。当前设置和报告快照不一致时,标记为 `changed_since_report`,不要直接沿用旧字段执行。
|
||||
|
||||
## 权限设置清单
|
||||
|
||||
```text
|
||||
范围:<explicit_list / wiki_space / wiki_node / drive_folder> <name-or-id>
|
||||
|
||||
| Path | URL | Type | link_share_entity | external_access_entity / external_access | invite_external | share_entity | manage_collaborator_entity | copy_entity | security_entity | comment_entity | lock_switch | sec_label_name | 建议动作 | 限制 |
|
||||
|------|-----|------|-------------------|------------------------------------------|-----------------|--------------|----------------------------|-------------|-----------------|----------------|-------------|----------------|----------|------|
|
||||
| <path> | <url-or-token> | <type> | <value> | <value> | <value-or-unknown> | <value> | <value-or-unknown> | <value-or-unknown> | <value> | <value> | <value-or-unknown> | <value-or-missing> | <recommended-action> | <unsupported-or-none> |
|
||||
```
|
||||
|
||||
## 访问复核清单
|
||||
|
||||
```text
|
||||
范围:<wiki_space / wiki_node / drive_folder / explicit_list> <name-or-id>
|
||||
复核对象数:<count>
|
||||
|
||||
| Owner | Path | URL | Type | 密级 | 风险标签 | 当前权限摘要 | 最近访问证据 | 建议动作 |
|
||||
|-------|------|-----|------|------|----------|--------------|--------------|----------|
|
||||
| <owner-or-unknown> | <path> | <url-or-token> | <type> | <sec-label-or-missing> | <labels> | <link/external/share/security/comment> | <uv/pv/last_view_or_unknown> | <keep / tighten / owner review / unsupported> |
|
||||
|
||||
限制:<unsupported_checks / discovery_blockers / none>
|
||||
```
|
||||
|
||||
## 整改 dry-run
|
||||
|
||||
```text
|
||||
将生成整改计划,不执行写入:
|
||||
- 范围:<scope>
|
||||
- 选择来源:<risk_id list / risk_group / selected=true artifact / URL list>
|
||||
- 候选目标数:<count>
|
||||
- 计划执行命令:<command family>
|
||||
- 重新读取:已对所选目标重新读取当前权限;changed_since_report=<count>
|
||||
- 字段变更:
|
||||
- <risk_id> <path> (<url-or-token>): <field> <old> -> <new>
|
||||
- 跳过项:<unsupported / no manage_public / unsupported type / missing policy>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚范围:<文档公共访问和协作权限设置快照字段 / 不适用>
|
||||
|
||||
请确认是否进入写入确认。
|
||||
```
|
||||
|
||||
## 批量权限申请确认
|
||||
|
||||
```text
|
||||
将逐个发起 <view / edit> 权限申请:
|
||||
- 候选目标数:<count>
|
||||
- 命令类型:drive +apply-permission
|
||||
- 风险:write;每个请求都会通知 owner
|
||||
- 执行方式:按候选列表顺序逐个调用,失败项会单独记录
|
||||
|
||||
候选示例:
|
||||
- <risk_id> <title> (<type>, <url-or-token>):<reason>
|
||||
|
||||
请确认是否对上述候选目标发起权限申请。
|
||||
```
|
||||
|
||||
## owner 转移确认
|
||||
|
||||
```text
|
||||
将逐个转移 owner:
|
||||
- 候选目标数:<count>
|
||||
- 命令类型:drive permission.members transfer_owner
|
||||
- 风险:high-risk-write;会改变文档 owner,可能影响原 owner 权限和文档所在位置
|
||||
- 新 owner 映射:<same_new_owner / per_target_new_owner>
|
||||
- 全局新 owner:<member_id> (<member_type>);仅当所有候选目标的新 owner 相同时展示,否则省略
|
||||
- 通知新 owner:<need_notification>
|
||||
- 原 owner 权限:<remove_old_owner=true / old_owner_perm>
|
||||
- 个人空间位置:<stay_put>
|
||||
- 执行方式:按候选列表顺序逐个调用,失败项会单独记录
|
||||
- 验证方式:执行后重新读取 metadata owner;metadata 不支持的类型标记为 partial
|
||||
- 回滚边界:不做自动回滚;如需恢复 owner,必须另起一次反向 owner 转移确认
|
||||
|
||||
候选示例:
|
||||
- <risk_id> <title> (<type>, <url-or-token>):当前 owner=<owner-or-unknown> -> 新 owner=<member_id> (<member_type>)
|
||||
|
||||
请确认是否对上述候选目标转移 owner。
|
||||
```
|
||||
|
||||
## 确认请求
|
||||
|
||||
```text
|
||||
将执行 <operation>:
|
||||
- 目标:<risk_id> <title> (<type>, <url-or-token>)
|
||||
- 命令类型:<command family>
|
||||
- 风险:<risk_level>
|
||||
- 字段变更:
|
||||
- <field>: <old> -> <new>
|
||||
- 验证方式:执行后重新读取 <元数据 / 文档公共访问和协作权限设置>
|
||||
- 有限回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
|
||||
请确认是否执行。
|
||||
```
|
||||
|
||||
## 最终摘要
|
||||
|
||||
```text
|
||||
已完成:<read checks / writes>
|
||||
验证:<fresh read result or async permission-request approval note>
|
||||
清单状态:<risk_id status updates / not applicable>
|
||||
回滚材料:<文档公共访问和协作权限设置快照 / 不适用>
|
||||
剩余限制:<unsupported_checks / partial facts / approvals>
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user