diff --git a/.gitignore b/.gitignore index 5666621b5..645848906 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ Thumbs.db # Go docs/ref docs/ +!tests/cli_e2e/docs/ +!tests/cli_e2e/docs/*.go +!tests/cli_e2e/docs/*.md vendor/ diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index fa91d8912..09cb33077 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -20,13 +20,28 @@ import ( "github.com/spf13/cobra" ) +func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command { + cmd := NewCmdApi(f, runF) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + return cmd +} + +func newTestRootCmd() *cobra.Command { + return &cobra.Command{ + Use: "lark-cli", + SilenceErrors: true, + SilenceUsage: true, + } +} + func TestApiCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"}) err := cmd.Execute() if err != nil { @@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"}) if err := cmd.Execute(); err != nil { t.Fatalf("--params null with --page-size should not error, got: %v", err) @@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) { Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}}, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"}) err := cmd.Execute() if err != nil { @@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET"}) // missing path err := cmd.Execute() if err == nil { @@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"}) err := cmd.Execute() if err == nil { @@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) fn := cmd.ValidArgsFunction tests := []struct { @@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) flag := cmd.Flags().Lookup("as") if flag == nil { t.Fatal("expected --as flag to be registered") @@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"}) err := cmd.Execute() if err == nil { @@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return apiRun(opts) }) @@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) { ContentType: "application/octet-stream", }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"}) err := cmd.Execute() if err != nil { @@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"}) err := cmd.Execute() if err != nil { @@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"}) err := cmd.Execute() // Should return an error @@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) err := cmd.Execute() if err != nil { @@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) err := cmd.Execute() if err == nil { @@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"}) if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) { }, }) - root := &cobra.Command{Use: "lark-cli"} - root.AddCommand(NewCmdApi(f, nil)) + root := newTestRootCmd() + root.AddCommand(newTestApiCmd(f, nil)) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"}) if err := root.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) { }, }) - root := &cobra.Command{Use: "lark-cli"} - root.AddCommand(NewCmdApi(f, nil)) + root := newTestRootCmd() + root.AddCommand(newTestApiCmd(f, nil)) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) if err := root.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) { }, }) - root := &cobra.Command{Use: "lark-cli"} - root.AddCommand(NewCmdApi(f, nil)) + root := newTestRootCmd() + root.AddCommand(newTestApiCmd(f, nil)) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) err := root.Execute() if err == nil { @@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"}) @@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"}) err := cmd.Execute() if err != nil { @@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"}) @@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["}) @@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"}) err := cmd.Execute() if err != nil { @@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"}) @@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"}) @@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"}) @@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"}) err := cmd.Execute() if err != nil { @@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"}) err := cmd.Execute() if err == nil { @@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) diff --git a/shortcuts/doc/docs_history.go b/shortcuts/doc/docs_history.go new file mode 100644 index 000000000..660dec42a --- /dev/null +++ b/shortcuts/doc/docs_history.go @@ -0,0 +1,258 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +type docsHistoryListSpec struct { + Doc documentRef + PageSize int + PageToken string +} + +type docsHistoryRevertSpec struct { + Doc documentRef + HistoryVersionID string + WaitTimeoutMs int +} + +type docsHistoryRevertStatusSpec struct { + Doc documentRef + TaskID string +} + +func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) { + ref, err := parseDocumentRef(raw) + if err != nil { + return documentRef{}, err + } + if ref.Kind == "doc" { + return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc") + } + return ref, nil +} + +func validateDocsHistoryPageSize(pageSize int) error { + if pageSize < 1 || pageSize > 20 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size") + } + return nil +} + +func validateDocsHistoryVersionID(historyVersionID string) error { + version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64) + if err != nil || version <= 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id") + } + return nil +} + +func validateDocsHistoryWaitTimeout(timeoutMs int) error { + if timeoutMs < 0 || timeoutMs > 30000 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms") + } + return nil +} + +func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} { + params := map[string]interface{}{ + "page_size": spec.PageSize, + } + if spec.PageToken != "" { + params["page_token"] = spec.PageToken + } + return params +} + +func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} { + return map[string]interface{}{ + "history_version_id": spec.HistoryVersionID, + "wait_timeout_ms": spec.WaitTimeoutMs, + } +} + +func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} { + return map[string]interface{}{ + "task_id": spec.TaskID, + } +} + +func docsHistoryAPIPath(docToken, suffix string) string { + return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix) +} + +var DocsHistoryList = common.Shortcut{ + Service: "docs", + Command: "+history-list", + Description: "List Lark document history versions", + Risk: "read", + Scopes: []string{"docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + PostMount: installDocsShortcutHelp("+history-list"), + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL or token", Required: true}, + {Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"}, + {Name: "page-token", Desc: "pagination token from the previous page's page_token"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil { + return err + } + return validateDocsHistoryPageSize(runtime.Int("page-size")) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list") + spec := docsHistoryListSpec{ + Doc: ref, + PageSize: runtime.Int("page-size"), + PageToken: strings.TrimSpace(runtime.Str("page-token")), + } + return common.NewDryRunAPI(). + Desc("OpenAPI: list document history versions"). + GET("/open-apis/docs_ai/v1/documents/:document_id/histories"). + Set("document_id", spec.Doc.Token). + Params(docsHistoryListParams(spec)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list") + spec := docsHistoryListSpec{ + Doc: ref, + PageSize: runtime.Int("page-size"), + PageToken: strings.TrimSpace(runtime.Str("page-token")), + } + + data, err := runtime.CallAPITyped( + http.MethodGet, + docsHistoryAPIPath(spec.Doc.Token, "histories"), + docsHistoryListParams(spec), + nil, + ) + if err != nil { + return err + } + runtime.OutRaw(data, nil) + return nil + }, +} + +var DocsHistoryRevert = common.Shortcut{ + Service: "docs", + Command: "+history-revert", + Description: "Revert a Lark document to a historical version", + Risk: "write", + Scopes: []string{"docx:document:write_only", "docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + PostMount: installDocsShortcutHelp("+history-revert"), + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL or token", Required: true}, + {Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true}, + {Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil { + return err + } + if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil { + return err + } + return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms")) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert") + spec := docsHistoryRevertSpec{ + Doc: ref, + HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")), + WaitTimeoutMs: runtime.Int("wait-timeout-ms"), + } + return common.NewDryRunAPI(). + Desc("OpenAPI: revert document history"). + POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert"). + Set("document_id", spec.Doc.Token). + Body(docsHistoryRevertBody(spec)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert") + spec := docsHistoryRevertSpec{ + Doc: ref, + HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")), + WaitTimeoutMs: runtime.Int("wait-timeout-ms"), + } + + data, err := runtime.CallAPITyped( + http.MethodPost, + docsHistoryAPIPath(spec.Doc.Token, "history/revert"), + nil, + docsHistoryRevertBody(spec), + ) + if err != nil { + return err + } + runtime.OutRaw(data, nil) + return nil + }, +} + +var DocsHistoryRevertStatus = common.Shortcut{ + Service: "docs", + Command: "+history-revert-status", + Description: "Get Lark document history revert task status", + Risk: "read", + Scopes: []string{"docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + PostMount: installDocsShortcutHelp("+history-revert-status"), + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL or token", Required: true}, + {Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("task-id")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status") + spec := docsHistoryRevertStatusSpec{ + Doc: ref, + TaskID: strings.TrimSpace(runtime.Str("task-id")), + } + return common.NewDryRunAPI(). + Desc("OpenAPI: get document history revert status"). + GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status"). + Set("document_id", spec.Doc.Token). + Params(docsHistoryStatusParams(spec)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status") + spec := docsHistoryRevertStatusSpec{ + Doc: ref, + TaskID: strings.TrimSpace(runtime.Str("task-id")), + } + + data, err := runtime.CallAPITyped( + http.MethodGet, + docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"), + docsHistoryStatusParams(spec), + nil, + ) + if err != nil { + return err + } + runtime.OutRaw(data, nil) + return nil + }, +} diff --git a/shortcuts/doc/docs_history_test.go b/shortcuts/doc/docs_history_test.go new file mode 100644 index 000000000..28431fe63 --- /dev/null +++ b/shortcuts/doc/docs_history_test.go @@ -0,0 +1,309 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestDocsHistoryValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + args []string + param string + }{ + { + name: "list rejects legacy doc URL", + shortcut: DocsHistoryList, + args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"}, + param: "--doc", + }, + { + name: "list rejects invalid page size", + shortcut: DocsHistoryList, + args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"}, + param: "--page-size", + }, + { + name: "revert rejects invalid history version id", + shortcut: DocsHistoryRevert, + args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"}, + param: "--history-version-id", + }, + { + name: "revert rejects invalid wait timeout", + shortcut: DocsHistoryRevert, + args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"}, + param: "--wait-timeout-ms", + }, + { + name: "status rejects empty task id", + shortcut: DocsHistoryRevertStatus, + args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"}, + param: "--task-id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation")) + err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout) + if err == nil { + t.Fatal("expected validation error, got nil") + } + _, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error is not typed: %T %v", err, err) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + if validationErr.Param != tt.param { + t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err) + } + }) + } +} + +func TestDocsHistoryDryRun(t *testing.T) { + t.Parallel() + + listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{ + "doc": "doxcnHistoryDryRun", + "page-size": "5", + "page-token": "page_token_1", + }) + listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil))) + if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want { + t.Fatalf("list dry-run URL = %q, want %q", got, want) + } + if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 { + t.Fatalf("list page_size = %d, want 5", got) + } + if got := listDry.API[0].Params["page_token"]; got != "page_token_1" { + t.Fatalf("list page_token = %#v, want page_token_1", got) + } + + revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{ + "doc": "doxcnHistoryDryRun", + "history-version-id": "42", + "wait-timeout-ms": "30000", + }) + revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil))) + if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want { + t.Fatalf("revert dry-run URL = %q, want %q", got, want) + } + if got := revertDry.API[0].Body["history_version_id"]; got != "42" { + t.Fatalf("revert history_version_id = %#v, want 42", got) + } + if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 { + t.Fatalf("revert wait_timeout_ms = %d, want 30000", got) + } + + statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{ + "doc": "doxcnHistoryDryRun", + "task-id": "task_1", + }) + statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil))) + if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want { + t.Fatalf("status dry-run URL = %q, want %q", got, want) + } + if got := statusDry.API[0].Params["task_id"]; got != "task_1" { + t.Fatalf("status task_id = %#v, want task_1", got) + } +} + +func TestDocsHistoryExecuteList(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list")) + stub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "entries": []interface{}{ + map[string]interface{}{ + "revision_id": float64(42), + "history_version_id": "11", + "edit_time": "1780000000", + "type": float64(1), + "editor_ids": []interface{}{"ou_1"}, + }, + }, + "has_more": true, + "page_token": "page_token_2", + }, + }, + } + reg.Register(stub) + + err := mountAndRunDocs(t, DocsHistoryList, []string{ + "+history-list", + "--doc", "doxcnHistory", + "--page-size", "5", + "--page-token", "page_token_1", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDocsHistoryEnvelope(t, stdout) + if got := data["page_token"]; got != "page_token_2" { + t.Fatalf("page_token = %#v, want page_token_2", got) + } + entries, _ := data["entries"].([]interface{}) + if len(entries) != 1 { + t.Fatalf("entries = %#v, want one entry", data["entries"]) + } +} + +func TestDocsHistoryExecuteRevert(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "task_id": "task_1", + "status": "running", + "history_version_id": "42", + "poll_after_ms": float64(10000), + }, + }, + } + reg.Register(stub) + + err := mountAndRunDocs(t, DocsHistoryRevert, []string{ + "+history-revert", + "--doc", "doxcnHistory", + "--history-version-id", "42", + "--wait-timeout-ms", "0", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody) + } + if got := body["history_version_id"]; got != "42" { + t.Fatalf("history_version_id = %#v, want 42", got) + } + if got := int(body["wait_timeout_ms"].(float64)); got != 0 { + t.Fatalf("wait_timeout_ms = %d, want 0", got) + } + + data := decodeDocsHistoryEnvelope(t, stdout) + if got := data["task_id"]; got != "task_1" { + t.Fatalf("task_id = %#v, want task_1", got) + } +} + +func TestDocsHistoryExecuteRevertStatus(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "status": "partial_failed", + "history_version_id": "11", + "failed_block_tokens": []interface{}{"blk_1"}, + }, + }, + }) + + err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{ + "+history-revert-status", + "--doc", "doxcnHistory", + "--task-id", "task_1", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDocsHistoryEnvelope(t, stdout) + if got := data["status"]; got != "partial_failed" { + t.Fatalf("status = %#v, want partial_failed", got) + } + if got := data["history_version_id"]; got != "11" { + t.Fatalf("history_version_id = %#v, want 11", got) + } + failed, _ := data["failed_block_tokens"].([]interface{}) + if len(failed) != 1 || failed[0] != "blk_1" { + t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"]) + } +} + +func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command { + t.Helper() + + cmd := &cobra.Command{Use: shortcut.Command} + for _, flag := range shortcut.Flags { + switch flag.Type { + case "int": + cmd.Flags().Int(flag.Name, 0, flag.Desc) + default: + cmd.Flags().String(flag.Name, flag.Default, flag.Desc) + } + } + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return cmd +} + +func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { + t.Helper() + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("missing data in envelope: %#v", envelope) + } + return data +} + +func TestDocsHistoryURLValidationMessage(t *testing.T) { + t.Parallel() + + _, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "only supports docx documents") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/doc/shortcuts.go b/shortcuts/doc/shortcuts.go index a4e9781f6..25c4e61c9 100644 --- a/shortcuts/doc/shortcuts.go +++ b/shortcuts/doc/shortcuts.go @@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string { return docsSkillReadCommand + " references/lark-doc-fetch.md" case "update": return docsSkillReadCommand + " references/lark-doc-update.md" + case "history-list", "history-revert", "history-revert-status": + return docsSkillReadCommand + " references/lark-doc-history.md" default: return docsSkillReadCommand } @@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string { return "lark-cli docs +fetch --help" case "update": return "lark-cli docs +update --help" + case "history-list": + return "lark-cli docs +history-list --help" + case "history-revert": + return "lark-cli docs +history-revert --help" + case "history-revert-status": + return "lark-cli docs +history-revert-status --help" default: return "lark-cli docs --help" } @@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut { DocsCreate, DocsFetch, DocsUpdate, + DocsHistoryList, + DocsHistoryRevert, + DocsHistoryRevertStatus, DocMediaInsert, DocMediaUpload, DocMediaPreview, diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 5137584b9..c9514b0cb 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) { } } +func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newRegisterTestFactory(t)) + + for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} { + cmd, _, err := program.Find([]string{"docs", name}) + if err != nil { + t.Fatalf("find docs %s shortcut: %v", name, err) + } + if cmd == nil || cmd.Name() != name { + t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd) + } + if cmd.Flags().Lookup("api-version") != nil { + t.Fatalf("docs %s should not expose --api-version", name) + } + if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") { + t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long) + } + } +} + func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) { program := &cobra.Command{Use: "root"} RegisterShortcuts(program, newRegisterTestFactory(t)) diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index de8035122..ae0159668 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -5,7 +5,7 @@ description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文 metadata: requires: bins: ["lark-cli"] - cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help" + cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +history-list --help; lark-cli docs +history-revert --help; lark-cli docs +history-revert-status --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help" --- # docs @@ -17,6 +17,7 @@ metadata: lark-cli docs +fetch --doc "文档URL或token" lark-cli docs +create --content '标题

内容

' lark-cli docs +update --doc "文档URL或token" --command append --content '

内容

' +lark-cli docs +history-list --doc "文档URL或token" ``` ## 前置条件 — 执行操作前必读 @@ -25,6 +26,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '

1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用) 2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`` / `` 输出结构) 3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.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) +4. **查看或回滚历史版本** → 必读 [`lark-doc-history.md`](references/lark-doc-history.md)(先 list 找 `history_version_id`,再 revert,必要时 status 轮询) **未读完以上文件就执行相应操作会导致参数选择错误或格式错误。** @@ -41,6 +43,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '

- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `完整 SVG`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 ``,再启动 SubAgent 读取 `lark-whiteboard` 写入 - 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview` - 用户明确说"下载素材" → 用 `lark-cli docs +media-download` +- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),通过分页的 `docs +history-list` 匹配 `history_version_id`,再用 `docs +history-revert --history-version-id ` 回滚;不要把 `revision_id` 直接传给回滚命令。同一个 `revision_id` 可能对应多个 `history_version_id`:先拉一页,未命中则继续翻页;命中后最多额外再拉一页补齐相邻候选,然后按 `edit_time` 选择最接近用户目标时间的记录;没有目标时间或无法可靠区分时,再让用户确认。 - 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover` - `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*` - 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`) @@ -66,6 +69,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs + [flags]`) | [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) | | [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) | | [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) | +| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status | | [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. | | [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) | | [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) | diff --git a/skills/lark-doc/references/lark-doc-history.md b/skills/lark-doc/references/lark-doc-history.md new file mode 100644 index 000000000..a4a7235be --- /dev/null +++ b/skills/lark-doc/references/lark-doc-history.md @@ -0,0 +1,107 @@ +# docs history(历史版本与回滚) + +用于查看 Docx 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。 + +## 安全流程 + +1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`。 +2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id`、`revision_id`、`edit_time`、`name/description`)并确认后回滚。 +3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。 +4. 再用 `+history-revert --history-version-id ` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`。 +5. 用 `+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`。 +6. 回滚完成后,用 `docs +fetch` 读取文档确认内容。 + +## 按 revision_id 或时间点回滚 + +当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是: + +1. 执行 `docs +history-list --doc ` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。 +2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。 +3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。 +4. 从最终匹配条目读取 `history_version_id`。`history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。 +5. 执行 `docs +history-revert --doc --history-version-id `。 + +候选确认时使用类似格式: + +```text +同一个 revision_id 命中多个历史版本,请确认要回滚哪一条: +- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=... +- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=... +``` + +## 命令 + +```bash +# 列出历史版本 +lark-cli docs +history-list --doc "" --page-size 20 + +# 翻页 +lark-cli docs +history-list --doc "" --page-size 20 --page-token "" + +# 回滚到指定 history_version_id(默认等待 30000ms) +lark-cli docs +history-revert --doc "" --history-version-id 42 + +# 只发起任务,不等待 +lark-cli docs +history-revert --doc "" --history-version-id 42 --wait-timeout-ms 0 + +# 查询回滚任务状态 +lark-cli docs +history-revert-status --doc "" --task-id "" +``` + +## 参数 + +| 命令 | 参数 | 必填 | 说明 | +|-|-|-|-| +| `+history-list` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL | +| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` | +| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` | +| `+history-revert` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL | +| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 | +| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` | +| `+history-revert-status` | `--doc` | 是 | 同一个文档 | +| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` | + +## 返回值要点 + +`+history-list` 返回: + +```json +{ + "entries": [ + { + "revision_id": 42, + "history_version_id": "11", + "edit_time": "1780000000", + "type": 1, + "name": "版本名", + "description": "版本说明", + "editor_ids": ["ou_xxx"] + } + ], + "has_more": true, + "page_token": "page_token" +} +``` + +`+history-revert` 返回: + +```json +{ + "task_id": "task_xxx", + "status": "running", + "history_version_id": "11", + "poll_after_ms": 10000 +} +``` + +`+history-revert-status` 返回: + +```json +{ + "status": "partial_failed", + "history_version_id": "11", + "failed_block_tokens": ["blk_xxx"] +} +``` + +`status` 可能是 `running`、`done`、`partial_failed`、`failed`。当状态是 `partial_failed` 或 `failed` 时,优先检查 `failed_block_tokens`。 diff --git a/tests/cli_e2e/docs/coverage.md b/tests/cli_e2e/docs/coverage.md index a35946f97..936a5ab2f 100644 --- a/tests/cli_e2e/docs/coverage.md +++ b/tests/cli_e2e/docs/coverage.md @@ -1,9 +1,9 @@ # Docs CLI E2E Coverage ## Metrics -- Denominator: 8 leaf commands -- Covered: 3 -- Coverage: 37.5% +- Denominator: 11 leaf commands +- Covered: 6 +- Coverage: 54.5% ## Summary - TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`. @@ -11,6 +11,8 @@ - TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes. - TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance. - TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `...` tag to request body `content`. +- TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes. +- TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content. - Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here. - Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration. @@ -20,6 +22,9 @@ | --- | --- | --- | --- | --- | --- | | ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content | | ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc `; `--doc-format markdown` | | +| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` | +| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` | +| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` | | ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet | | ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions | | ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture | diff --git a/tests/cli_e2e/docs/docs_history_workflow_test.go b/tests/cli_e2e/docs/docs_history_workflow_test.go new file mode 100644 index 000000000..eb16000f2 --- /dev/null +++ b/tests/cli_e2e/docs/docs_history_workflow_test.go @@ -0,0 +1,135 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package docs + +import ( + "context" + "os" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/larksuite/cli/tests/cli_e2e/drive" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDocs_HistoryWorkflow(t *testing.T) { + if os.Getenv("LARK_DOC_HISTORY_E2E") != "1" { + t.Skip("set LARK_DOC_HISTORY_E2E=1 to run docs history live workflow") + } + clie2e.SkipWithoutUserToken(t) + + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + folderName := "lark-cli-e2e-docs-history-folder-" + suffix + docTitle := "lark-cli-e2e-docs-history-" + suffix + originalMarker := "original history marker " + suffix + updatedMarker := "updated history marker " + suffix + const defaultAs = "user" + + folderToken := drive.CreateDriveFolder(t, parentT, ctx, folderName, defaultAs, "") + docToken := createDocWithRetry(t, parentT, ctx, folderToken, docTitle, originalMarker, defaultAs) + + updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+update", + "--doc", docToken, + "--command", "overwrite", + "--doc-format", "markdown", + "--content", "# " + docTitle + "\n\n" + updatedMarker, + }, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + updateResult.AssertExitCode(t, 0) + updateResult.AssertStdoutStatus(t, true) + + fetchUpdated, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"}, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + fetchUpdated.AssertExitCode(t, 0) + fetchUpdated.AssertStdoutStatus(t, true) + updatedContent := gjson.Get(fetchUpdated.Stdout, "data.document.content").String() + assert.Contains(t, updatedContent, updatedMarker) + currentRevision := gjson.Get(fetchUpdated.Stdout, "data.document.revision_id").Int() + require.Greater(t, currentRevision, int64(0), "stdout:\n%s", fetchUpdated.Stdout) + + var revertHistoryVersionID string + require.Eventually(t, func() bool { + listResult, listErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+history-list", + "--doc", docToken, + "--page-size", "20", + }, + DefaultAs: defaultAs, + }) + if listErr != nil || listResult.ExitCode != 0 { + return false + } + for _, entry := range gjson.Get(listResult.Stdout, "data.entries").Array() { + revisionID := entry.Get("revision_id").Int() + historyVersionID := entry.Get("history_version_id").String() + if revisionID > 0 && revisionID < currentRevision && historyVersionID != "" { + revertHistoryVersionID = historyVersionID + return true + } + } + return false + }, 45*time.Second, 3*time.Second, "history list did not expose a prior revision") + require.NotEmpty(t, revertHistoryVersionID) + + revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+history-revert", + "--doc", docToken, + "--history-version-id", revertHistoryVersionID, + "--wait-timeout-ms", "30000", + }, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + revertResult.AssertExitCode(t, 0) + revertResult.AssertStdoutStatus(t, true) + + status := gjson.Get(revertResult.Stdout, "data.status").String() + taskID := gjson.Get(revertResult.Stdout, "data.task_id").String() + if status == "running" { + require.NotEmpty(t, taskID, "stdout:\n%s", revertResult.Stdout) + require.Eventually(t, func() bool { + statusResult, statusErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "docs", "+history-revert-status", + "--doc", docToken, + "--task-id", taskID, + }, + DefaultAs: defaultAs, + }) + if statusErr != nil || statusResult.ExitCode != 0 { + return false + } + status = gjson.Get(statusResult.Stdout, "data.status").String() + return status != "" && status != "running" + }, 60*time.Second, 5*time.Second, "history revert task did not finish") + } + require.Equal(t, "done", status, "revert stdout:\n%s", revertResult.Stdout) + + fetchReverted, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"}, + DefaultAs: defaultAs, + }) + require.NoError(t, err) + fetchReverted.AssertExitCode(t, 0) + fetchReverted.AssertStdoutStatus(t, true) + revertedContent := gjson.Get(fetchReverted.Stdout, "data.document.content").String() + assert.Contains(t, revertedContent, originalMarker) + assert.NotContains(t, revertedContent, updatedMarker) +} diff --git a/tests/cli_e2e/docs/docs_update_dryrun_test.go b/tests/cli_e2e/docs/docs_update_dryrun_test.go index c0bd05864..bb8ec91d0 100644 --- a/tests/cli_e2e/docs/docs_update_dryrun_test.go +++ b/tests/cli_e2e/docs/docs_update_dryrun_test.go @@ -24,9 +24,9 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { t.Cleanup(cancel) tests := []struct { - name string - args []string - wantURL string + name string + args []string + wantContains []string }{ { name: "create", @@ -35,7 +35,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { "--content", "Dry Run

hello

", "--dry-run", }, - wantURL: "/open-apis/docs_ai/v1/documents", + wantContains: []string{"/open-apis/docs_ai/v1/documents"}, }, { name: "create api-version v1 compatibility", @@ -45,7 +45,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { "--content", "Dry Run

hello

", "--dry-run", }, - wantURL: "/open-apis/docs_ai/v1/documents", + wantContains: []string{"/open-apis/docs_ai/v1/documents"}, }, { name: "fetch", @@ -54,7 +54,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { "--doc", "doxcnDryRunE2E", "--dry-run", }, - wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch", + wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch"}, }, { name: "update", @@ -65,7 +65,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { "--content", "

hello

", "--dry-run", }, - wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E", + wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"}, }, { name: "block_delete batch", @@ -76,7 +76,50 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { "--block-id", "blkA,blkB,blkC", "--dry-run", }, - wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E", + wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"}, + }, + { + name: "history list", + args: []string{ + "docs", "+history-list", + "--doc", "doxcnDryRunE2E", + "--page-size", "5", + "--page-token", "page_token_1", + "--dry-run", + }, + wantContains: []string{ + "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/histories", + `"page_size": 5`, + `"page_token": "page_token_1"`, + }, + }, + { + name: "history revert", + args: []string{ + "docs", "+history-revert", + "--doc", "doxcnDryRunE2E", + "--history-version-id", "42", + "--wait-timeout-ms", "0", + "--dry-run", + }, + wantContains: []string{ + "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert", + `"history_version_id": "42"`, + `"wait_timeout_ms": 0`, + }, + }, + { + name: "history revert status", + args: []string{ + "docs", "+history-revert-status", + "--doc", "doxcnDryRunE2E", + "--task-id", "task_1", + "--dry-run", + }, + wantContains: []string{ + "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert_status", + `"task_id": "task_1"`, + }, }, } @@ -90,10 +133,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) { result.AssertExitCode(t, 0) combined := result.Stdout + "\n" + result.Stderr - for _, want := range []string{ - tt.wantURL, - "docs_ai/v1", - } { + for _, want := range append(tt.wantContains, "docs_ai/v1") { if !strings.Contains(combined, want) { t.Fatalf("dry-run output missing %q\nstdout:\n%s\nstderr:\n%s", want, result.Stdout, result.Stderr) }