From bbaaefff1db8afd4aea42b34f161bf97c63a2d43 Mon Sep 17 00:00:00 2001 From: wangweiming Date: Thu, 9 Jul 2026 22:02:41 +0800 Subject: [PATCH] feat: add drive list comments shortcut --- shortcuts/drive/drive_list_comments.go | 336 ++++++++++++++++ shortcuts/drive/drive_list_comments_test.go | 365 ++++++++++++++++++ shortcuts/drive/shortcuts.go | 1 + shortcuts/drive/shortcuts_test.go | 9 +- skills/lark-drive/SKILL.md | 9 +- .../references/lark-drive-comment-location.md | 20 +- .../references/lark-drive-comments-guide.md | 24 +- .../references/lark-drive-list-comments.md | 106 +++++ tests/cli_e2e/drive/coverage.md | 10 +- .../drive/drive_add_comment_workflow_test.go | 37 ++ .../drive/drive_list_comments_dryrun_test.go | 100 +++++ 11 files changed, 994 insertions(+), 23 deletions(-) create mode 100644 shortcuts/drive/drive_list_comments.go create mode 100644 shortcuts/drive/drive_list_comments_test.go create mode 100644 skills/lark-drive/references/lark-drive-list-comments.md create mode 100644 tests/cli_e2e/drive/drive_list_comments_dryrun_test.go diff --git a/shortcuts/drive/drive_list_comments.go b/shortcuts/drive/drive_list_comments.go new file mode 100644 index 000000000..9f66a875d --- /dev/null +++ b/shortcuts/drive/drive_list_comments.go @@ -0,0 +1,336 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + driveListCommentsDefaultPageSize = 50 + driveListCommentsDefaultSolvedStatus = "false" + driveListCommentsDefaultScope = "all" + driveListCommentsDefaultUserIDType = "open_id" +) + +var driveListCommentsTypes = []string{"doc", "docx", "sheet", "file", "slides", "bitable", "base", "wiki"} + +type driveListCommentsRef struct { + Token string + Type string + SourceFlag string +} + +type driveListCommentsTarget struct { + FileToken string + FileType string +} + +type driveListCommentsSpec struct { + Ref driveListCommentsRef + PageSize int + PageToken string + SolvedStatus string + CommentScope string + NeedReaction bool + NeedRelation bool + UserIDType string +} + +// DriveListComments lists document comments through the Drive comments API, +// while accepting Wiki URLs/tokens and resolving them to the underlying object. +var DriveListComments = common.Shortcut{ + Service: "drive", + Command: "+list-comments", + Description: "List comments for doc/docx/sheet/file/slides/base(bitable), with URL parsing and Wiki token unwrapping", + Risk: "read", + Scopes: []string{"docs:document.comment:read"}, + ConditionalScopes: []string{"wiki:node:retrieve"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/slides/base/bitable/wiki); Wiki URLs are unwrapped automatically"}, + {Name: "token", Desc: "document token, Wiki token, or document URL; bare tokens require --type"}, + {Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveListCommentsTypes}, + {Name: "solved-status", Default: driveListCommentsDefaultSolvedStatus, Desc: "comment solved filter: false=unresolved, true=solved, all=all comments", Enum: []string{"false", "true", "all"}}, + {Name: "comment-scope", Default: driveListCommentsDefaultScope, Desc: "comment scope filter: all=all comments, whole=full-document comments, partial=local/selection comments", Enum: []string{"all", "whole", "partial"}}, + {Name: "need-reaction", Type: "bool", Desc: "include reaction data on comment cards"}, + {Name: "need-relation", Type: "bool", Desc: "include docx comment relation data; ignored for non-docx targets"}, + {Name: "page-size", Type: "int", Default: "50", Desc: "page size, 1-100"}, + {Name: "page-token", Desc: "pagination token from previous response"}, + {Name: "user-id-type", Default: driveListCommentsDefaultUserIDType, Desc: "user ID type in the response", Enum: []string{"user_id", "union_id", "open_id"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec, err := readDriveListCommentsSpec(runtime) + if err != nil { + return err + } + return validateDriveListCommentsSpec(spec) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec, err := readDriveListCommentsSpec(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + if err := validateDriveListCommentsSpec(spec); err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return buildDriveListCommentsDryRun(spec) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec, err := readDriveListCommentsSpec(runtime) + if err != nil { + return err + } + if err := validateDriveListCommentsSpec(spec); err != nil { + return err + } + + target, err := resolveDriveListCommentsTarget(ctx, runtime, spec.Ref) + if err != nil { + return err + } + params := buildDriveListCommentsParams(spec, target.FileType) + path := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments", validate.EncodePathSegment(target.FileToken)) + + data, err := runtime.CallAPITyped("GET", path, params, nil) + if err != nil { + return err + } + runtime.Out(buildDriveListCommentsOutput(target, data), nil) + return nil + }, +} + +func readDriveListCommentsSpec(runtime *common.RuntimeContext) (driveListCommentsSpec, error) { + ref, err := resolveDriveListCommentsInput(runtime.Str("url"), runtime.Str("token"), runtime.Str("type")) + if err != nil { + return driveListCommentsSpec{}, err + } + return driveListCommentsSpec{ + Ref: ref, + PageSize: runtime.Int("page-size"), + PageToken: strings.TrimSpace(runtime.Str("page-token")), + SolvedStatus: strings.TrimSpace(runtime.Str("solved-status")), + CommentScope: strings.TrimSpace(runtime.Str("comment-scope")), + NeedReaction: runtime.Bool("need-reaction"), + NeedRelation: runtime.Bool("need-relation"), + UserIDType: strings.TrimSpace(runtime.Str("user-id-type")), + }, nil +} + +func validateDriveListCommentsSpec(spec driveListCommentsSpec) error { + if spec.PageSize < 1 || spec.PageSize > 100 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be between 1 and 100").WithParam("--page-size") + } + if _, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); !ok { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --solved-status %q; allowed: false, true, all", spec.SolvedStatus).WithParam("--solved-status") + } + if _, ok := driveListCommentsScopeParam(spec.CommentScope); !ok { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --comment-scope %q; allowed: all, whole, partial", spec.CommentScope).WithParam("--comment-scope") + } + if spec.UserIDType != "user_id" && spec.UserIDType != "union_id" && spec.UserIDType != "open_id" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --user-id-type %q; allowed: user_id, union_id, open_id", spec.UserIDType).WithParam("--user-id-type") + } + return nil +} + +func resolveDriveListCommentsInput(urlInput, tokenInput, explicitType string) (driveListCommentsRef, error) { + urlInput = strings.TrimSpace(urlInput) + tokenInput = strings.TrimSpace(tokenInput) + if urlInput != "" && tokenInput != "" { + return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url") + } + if urlInput == "" && tokenInput == "" { + return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url") + } + + raw := urlInput + sourceFlag := "--url" + if raw == "" { + raw = tokenInput + sourceFlag = "--token" + } + inputType := normalizeDriveListCommentsType(strings.ToLower(strings.TrimSpace(explicitType))) + + if ref, ok := common.ParseResourceURL(raw); ok { + refType := normalizeDriveListCommentsType(ref.Type) + if inputType != "" && inputType != refType { + return driveListCommentsRef{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--type %q conflicts with URL path type %q; remove --type or use a matching value", + inputType, + refType, + ).WithParam("--type") + } + if !driveListCommentsTypeSupported(refType) { + return driveListCommentsRef{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "unsupported %s resource type %q; comments list supports doc, docx, sheet, file, slides, bitable/base, and wiki", + sourceFlag, + refType, + ).WithParam(sourceFlag) + } + return driveListCommentsRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil + } + + if strings.Contains(raw, "://") { + return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag) + } + if strings.ContainsAny(raw, "/?#") { + return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag) + } + if inputType == "" { + return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, slides, bitable, base, wiki)", sourceFlag).WithParam("--type") + } + if !driveListCommentsTypeSupported(inputType) { + return driveListCommentsRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, slides, bitable, base, wiki", inputType).WithParam("--type") + } + return driveListCommentsRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil +} + +func normalizeDriveListCommentsType(docType string) string { + switch strings.TrimSpace(docType) { + case "base": + return "bitable" + default: + return strings.TrimSpace(docType) + } +} + +func driveListCommentsTypeSupported(docType string) bool { + switch normalizeDriveListCommentsType(docType) { + case "doc", "docx", "sheet", "file", "slides", "bitable", "wiki": + return true + default: + return false + } +} + +func resolveDriveListCommentsTarget(ctx context.Context, runtime *common.RuntimeContext, ref driveListCommentsRef) (driveListCommentsTarget, error) { + if ref.Type != "wiki" { + return driveListCommentsTarget{FileToken: ref.Token, FileType: ref.Type}, nil + } + + fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(ref.Token)) + data, err := runtime.CallAPITyped( + "GET", + "/open-apis/wiki/v2/spaces/get_node", + map[string]interface{}{"token": ref.Token}, + nil, + ) + if err != nil { + return driveListCommentsTarget{}, err + } + + node := common.GetMap(data, "node") + objType := normalizeDriveListCommentsType(common.GetString(node, "obj_type")) + objToken := common.GetString(node, "obj_token") + if objType == "" || objToken == "" { + return driveListCommentsTarget{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data") + } + if !driveListCommentsTypeSupported(objType) || objType == "wiki" { + return driveListCommentsTarget{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "wiki resolved to %q, but comments list only supports doc, docx, sheet, file, slides, and bitable", + objType, + ).WithParam(ref.SourceFlag) + } + fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken)) + return driveListCommentsTarget{FileToken: objToken, FileType: objType}, nil +} + +func buildDriveListCommentsDryRun(spec driveListCommentsSpec) *common.DryRunAPI { + if spec.Ref.Type == "wiki" { + params := buildDriveListCommentsParams(spec, "") + if spec.NeedRelation { + params["need_relation"] = "" + } + return common.NewDryRunAPI(). + Desc("2-step orchestration: resolve wiki -> list comments"). + GET("/open-apis/wiki/v2/spaces/get_node"). + Desc("[1] Resolve wiki node to underlying document"). + Params(map[string]interface{}{"token": spec.Ref.Token}). + GET("/open-apis/drive/v1/files//comments"). + Desc("[2] List comments on resolved document"). + Params(params) + } + + return common.NewDryRunAPI(). + Desc("1-step request: list comments"). + GET("/open-apis/drive/v1/files/:file_token/comments"). + Params(buildDriveListCommentsParams(spec, spec.Ref.Type)). + Set("file_token", spec.Ref.Token) +} + +func buildDriveListCommentsParams(spec driveListCommentsSpec, fileType string) map[string]interface{} { + params := map[string]interface{}{ + "file_type": fileType, + "page_size": spec.PageSize, + "user_id_type": spec.UserIDType, + } + if spec.PageToken != "" { + params["page_token"] = spec.PageToken + } + if value, ok := driveListCommentsSolvedStatusParam(spec.SolvedStatus); ok && value != nil { + params["is_solved"] = *value + } + if value, ok := driveListCommentsScopeParam(spec.CommentScope); ok && value != nil { + params["is_whole"] = *value + } + if spec.NeedReaction { + params["need_reaction"] = true + } + if spec.NeedRelation && fileType == "docx" { + params["need_relation"] = true + } + return params +} + +func driveListCommentsSolvedStatusParam(status string) (*bool, bool) { + switch strings.TrimSpace(status) { + case "false", "": + value := false + return &value, true + case "true": + value := true + return &value, true + case "all": + return nil, true + default: + return nil, false + } +} + +func driveListCommentsScopeParam(scope string) (*bool, bool) { + switch strings.TrimSpace(scope) { + case "all", "": + return nil, true + case "whole": + value := true + return &value, true + case "partial": + value := false + return &value, true + default: + return nil, false + } +} + +func buildDriveListCommentsOutput(target driveListCommentsTarget, data map[string]interface{}) map[string]interface{} { + items := common.GetSlice(data, "items") + return map[string]interface{}{ + "file_token": target.FileToken, + "file_type": target.FileType, + "items": items, + "has_more": common.GetBool(data, "has_more"), + "page_token": common.GetString(data, "page_token"), + "count": len(items), + } +} diff --git a/shortcuts/drive/drive_list_comments_test.go b/shortcuts/drive/drive_list_comments_test.go new file mode 100644 index 000000000..8692fdec2 --- /dev/null +++ b/shortcuts/drive/drive_list_comments_test.go @@ -0,0 +1,365 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "errors" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestResolveDriveListCommentsInput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + urlInput string + rawInput string + docType string + wantResource string + wantType string + wantErr string + wantParam string + }{ + { + name: "url docx", + urlInput: "https://example.larksuite.com/docx/docxResource?from=wiki", + wantResource: "docxResource", + wantType: "docx", + }, + { + name: "token flag also accepts url", + rawInput: "https://example.larksuite.com/base/bitableResource", + wantResource: "bitableResource", + wantType: "bitable", + }, + { + name: "bare wiki token", + rawInput: "wikiResource", + docType: "wiki", + wantResource: "wikiResource", + wantType: "wiki", + }, + { + name: "url and token mutually exclusive", + urlInput: "https://example.larksuite.com/docx/docxResource", + rawInput: "docxResource", + wantErr: "mutually exclusive", + wantParam: "--url", + }, + { + name: "bare token needs type", + rawInput: "docxResource", + wantErr: "--type is required", + wantParam: "--type", + }, + { + name: "type conflicts with url", + urlInput: "https://example.larksuite.com/wiki/wikiResource", + docType: "docx", + wantErr: "conflicts", + wantParam: "--type", + }, + { + name: "unsupported url type", + urlInput: "https://example.larksuite.com/drive/folder/folderResource", + wantErr: "unsupported", + wantParam: "--url", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := resolveDriveListCommentsInput(tt.urlInput, tt.rawInput, tt.docType) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + assertDriveListCommentsValidationError(t, err, tt.wantParam) + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Token != tt.wantResource || got.Type != tt.wantType { + t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantResource, tt.wantType) + } + }) + } +} + +func TestValidateDriveListCommentsSpec(t *testing.T) { + t.Parallel() + + valid := driveListCommentsSpec{ + PageSize: 50, + SolvedStatus: "false", + CommentScope: "all", + UserIDType: "open_id", + } + + tests := []struct { + name string + mutate func(*driveListCommentsSpec) + wantParam string + }{ + { + name: "invalid page size", + mutate: func(spec *driveListCommentsSpec) { + spec.PageSize = 0 + }, + wantParam: "--page-size", + }, + { + name: "invalid solved status", + mutate: func(spec *driveListCommentsSpec) { + spec.SolvedStatus = "open" + }, + wantParam: "--solved-status", + }, + { + name: "invalid comment scope", + mutate: func(spec *driveListCommentsSpec) { + spec.CommentScope = "inline" + }, + wantParam: "--comment-scope", + }, + { + name: "invalid user id type", + mutate: func(spec *driveListCommentsSpec) { + spec.UserIDType = "email" + }, + wantParam: "--user-id-type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + spec := valid + tt.mutate(&spec) + err := validateDriveListCommentsSpec(spec) + if err == nil { + t.Fatal("expected validation error, got nil") + } + assertDriveListCommentsValidationError(t, err, tt.wantParam) + }) + } +} + +func assertDriveListCommentsValidationError(t *testing.T, err error, wantParam string) { + t.Helper() + + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if validationErr.Category != errs.CategoryValidation { + t.Fatalf("category = %q, want %q", validationErr.Category, errs.CategoryValidation) + } + if validationErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument) + } + if validationErr.Param != wantParam { + t.Fatalf("param = %q, want %q", validationErr.Param, wantParam) + } + if cause := errors.Unwrap(err); cause != nil { + t.Fatalf("unexpected cause on direct validation error: %v", cause) + } + + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected errs.ProblemOf to recognize typed error: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("problem category = %q, want %q", problem.Category, errs.CategoryValidation) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument) + } +} + +func TestBuildDriveListCommentsParams(t *testing.T) { + t.Parallel() + + defaultSpec := driveListCommentsSpec{ + PageSize: 50, + SolvedStatus: "false", + CommentScope: "all", + UserIDType: "open_id", + } + defaultParams := buildDriveListCommentsParams(defaultSpec, "docx") + if got := defaultParams["is_solved"]; got != false { + t.Fatalf("default is_solved = %#v, want false", got) + } + if _, ok := defaultParams["is_whole"]; ok { + t.Fatalf("default params should omit is_whole: %#v", defaultParams) + } + if got := defaultParams["user_id_type"]; got != "open_id" { + t.Fatalf("user_id_type = %#v, want open_id", got) + } + + allPartialSpec := driveListCommentsSpec{ + PageSize: 100, + PageToken: "next", + SolvedStatus: "all", + CommentScope: "partial", + NeedReaction: true, + NeedRelation: true, + UserIDType: "union_id", + } + allPartialParams := buildDriveListCommentsParams(allPartialSpec, "docx") + if _, ok := allPartialParams["is_solved"]; ok { + t.Fatalf("solved-status all should omit is_solved: %#v", allPartialParams) + } + if got := allPartialParams["is_whole"]; got != false { + t.Fatalf("comment-scope partial is_whole = %#v, want false", got) + } + if got := allPartialParams["need_reaction"]; got != true { + t.Fatalf("need_reaction = %#v, want true", got) + } + if got := allPartialParams["need_relation"]; got != true { + t.Fatalf("need_relation = %#v, want true for docx", got) + } + if got := allPartialParams["page_token"]; got != "next" { + t.Fatalf("page_token = %#v, want next", got) + } + + sheetParams := buildDriveListCommentsParams(allPartialSpec, "sheet") + if _, ok := sheetParams["need_relation"]; ok { + t.Fatalf("need_relation should be ignored for non-docx: %#v", sheetParams) + } +} + +func TestDriveListCommentsExecuteDocx(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/docxResource/comments", + OnMatch: func(req *http.Request) { + query := req.URL.Query() + if got := query.Get("file_type"); got != "docx" { + t.Errorf("file_type = %q, want docx", got) + } + if got := query.Get("is_solved"); got != "false" { + t.Errorf("is_solved = %q, want false", got) + } + if got := query.Get("is_whole"); got != "" { + t.Errorf("is_whole = %q, want omitted", got) + } + if got := query.Get("user_id_type"); got != "open_id" { + t.Errorf("user_id_type = %q, want open_id", got) + } + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "items": []map[string]interface{}{ + {"comment_id": "comment_1", "is_solved": false}, + }, + "has_more": true, + "page_token": "next", + }, + }, + }) + + err := mountAndRunDrive(t, DriveListComments, []string{ + "+list-comments", + "--url", "https://example.larksuite.com/docx/docxResource", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := decodeJSONMap(t, stdout.String()) + data := mustMapValue(t, out["data"], "data") + if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxResource" { + t.Fatalf("file_token = %q, want docxResource", got) + } + if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" { + t.Fatalf("file_type = %q, want docx", got) + } + if got := data["count"]; got != float64(1) { + t.Fatalf("count = %#v, want 1", got) + } +} + +func TestDriveListCommentsExecuteWikiResolvesToDocx(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/wiki/v2/spaces/get_node", + OnMatch: func(req *http.Request) { + if got := req.URL.Query().Get("token"); got != "wikiResource" { + t.Errorf("wiki token = %q, want wikiResource", got) + } + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "node": map[string]interface{}{ + "obj_type": "docx", + "obj_token": "docxFromWikiResource", + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/docxFromWikiResource/comments", + OnMatch: func(req *http.Request) { + query := req.URL.Query() + if got := query.Get("is_solved"); got != "" { + t.Errorf("is_solved = %q, want omitted for solved-status all", got) + } + if got := query.Get("is_whole"); got != "true" { + t.Errorf("is_whole = %q, want true", got) + } + if got := query.Get("need_relation"); got != "true" { + t.Errorf("need_relation = %q, want true for resolved docx", got) + } + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "items": []map[string]interface{}{}, + "has_more": false, + }, + }, + }) + + err := mountAndRunDrive(t, DriveListComments, []string{ + "+list-comments", + "--token", "wikiResource", + "--type", "wiki", + "--solved-status", "all", + "--comment-scope", "whole", + "--need-relation", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := decodeJSONMap(t, stdout.String()) + data := mustMapValue(t, out["data"], "data") + if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxFromWikiResource" { + t.Fatalf("file_token = %q, want docxFromWikiResource", got) + } + if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" { + t.Fatalf("file_type = %q, want docx", got) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index 281f8b9d8..d274674c9 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -15,6 +15,7 @@ func Shortcuts() []common.Shortcut { DrivePreview, DriveCover, DriveAddComment, + DriveListComments, DriveExport, DriveExportDownload, DriveImport, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 5c12f3058..fc7a57d61 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -20,14 +20,15 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+download", "+preview", "+cover", + "+add-comment", + "+list-comments", + "+export", + "+export-download", + "+import", "+version-history", "+version-get", "+version-revert", "+version-delete", - "+add-comment", - "+export", - "+export-download", - "+import", "+move", "+delete", "+status", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 86bf869f2..9d1e34f3c 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -26,7 +26,8 @@ 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.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。 - 用户要**搜文档 / 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) 了解。 +- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url ''`,不要优先手写 `drive file.comments list`;具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。 +- 用户要**根据文档评论定位正文位置**,例如 根据评论 review 文档、根据评论内容回看文档、区分多处相同引用文本时,对于 docx 类型(`file_type=docx`)的文档支持通过 `drive +list-comments --need-relation` 返回评论位置,其他类型会静默忽略该参数;具体用法需要先阅读 [`references/lark-drive-comment-location.md`](references/lark-drive-comment-location.md) 了解。 - 用户给出 doubao.com 的云空间资源 URL/token,或明确提到豆包里的 file/folder/docx/sheet/bitable/wiki 资源时,仍按资源类型、URL 路径和 token 路由到本 skill;不要因为域名不是飞书而回退到 WebFetch。 - 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。 - 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。 @@ -80,13 +81,14 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX' | 添加全文评论 | `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 | -| 列出文档评论 | `file_token` | 同添加评论 | +| 列出文档评论 | URL 或 `file_token` | 优先使用 `drive +list-comments --url ''`;wiki URL/token 会自动解析到底层真实 token/type | ### 评论能力入口 - 添加评论优先使用 [`+add-comment`](references/lark-drive-add-comment.md):review / 审阅 / 校对场景默认尽量创建局部评论,不要把多个可定位问题合并为一条全文评论。 +- 获取评论列表优先使用 [`+list-comments`](references/lark-drive-list-comments.md):推荐传 `--url`,支持 wiki 自动解包;参数细节见 reference。 - 评论查询、统计、排序、回复限制,先读 [`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);其他文档类型暂不支持返回定位字段。 +- 需要根据评论定位正文位置时,先确认目标是 `file_type=docx`,再读 [`lark-drive-comment-location.md`](references/lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`;其他文档类型会静默忽略该参数。 - 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 !`;CLI 会将其拆分后写入 `anchor.block_id` 和 `anchor.slide_block_type`。其中 `` 是 PPT XML 协议中的元素 `id`;不支持 `--selection-with-ellipsis` 和 `--full-comment`。 @@ -139,6 +141,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+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)。 | +| [`+list-comments`](references/lark-drive-list-comments.md) | 获取 doc/docx/sheet/file/slides/base(bitable) 评论列表;优先传 URL,支持 wiki 自动解包。 | | [`+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) | 将本地文件导入为飞书在线文档、表格、多维表格或幻灯片。 | diff --git a/skills/lark-drive/references/lark-drive-comment-location.md b/skills/lark-drive/references/lark-drive-comment-location.md index cbb5a6b63..d882f6e5d 100644 --- a/skills/lark-drive/references/lark-drive-comment-location.md +++ b/skills/lark-drive/references/lark-drive-comment-location.md @@ -1,15 +1,27 @@ # 文档评论定位字段 -当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本,或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,docx 文档的评论查询必须带 `need_relation=true`。 +当用户需要根据评论定位文档正文位置、对文档做 review、区分多处相同引用文本,或把评论落点映射到 `docs +fetch --detail with-ids` 的内容时,优先使用 `drive +list-comments --need-relation` 查询 docx 评论位置。 ## 适用范围 - 当前只有 `file_type=docx` 支持通过 `need_relation=true` 查询评论的位置,并返回可用于定位正文 block 的 `relation`、`parent_type`、`parent_token` 等字段。 -- 其他文件类型暂不支持通过 `need_relation` 查询评论位置。遇到 sheet、bitable、slides、普通文件等类型的评论时,不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。 +- `drive +list-comments` 会在目标不是 docx 时静默忽略 `--need-relation`,避免把无效参数传给 OpenAPI。遇到 sheet、bitable、slides、普通文件等类型的评论时,不要承诺可以用 `need_relation` 精确定位正文位置,应退回普通评论字段、对应资源能力下钻或人工确认。 ## 调用方式 -分页列出评论时,把 `need_relation` 放在 query params: +分页列出评论时,优先传 URL;Wiki URL / Wiki token 会自动解析到底层真实 token/type: + +```bash +lark-cli drive +list-comments --url '' --need-relation +``` + +如果只有 Wiki token,显式传 `--type wiki`: + +```bash +lark-cli drive +list-comments --token '' --type wiki --need-relation +``` + +只有在需要未被 shortcut 暴露的底层参数时,才直接调用 raw OpenAPI。此时把 `need_relation` 放在 query params: ```bash lark-cli drive file.comments list \ @@ -126,7 +138,7 @@ lark-cli docs +fetch --doc '' --detail with-ids ## 定位流程 1. 确认目标是 `file_type=docx`;只有 docx 文档支持通过 `need_relation` 查询评论位置。 -2. 用 `drive file.comments list` 或 `drive file.comments batch_query` 获取评论,并带 `need_relation=true`。 +2. 用 `drive +list-comments --need-relation` 获取评论;已知评论 ID 且需要批量查询时,可用 `drive file.comments batch_query` 并带 `need_relation=true`。raw `drive file.comments list` 仅作为低层参数兜底。 3. 用 `docs +fetch --detail with-ids` 获取文档内容。 4. 对每条评论先看 `relation`: - 如果存在 `relation.relation`,解析这个 JSON 字符串。 diff --git a/skills/lark-drive/references/lark-drive-comments-guide.md b/skills/lark-drive/references/lark-drive-comments-guide.md index 71c4cb396..46e9d6020 100644 --- a/skills/lark-drive/references/lark-drive-comments-guide.md +++ b/skills/lark-drive/references/lark-drive-comments-guide.md @@ -1,6 +1,6 @@ # Drive 评论查询、统计与回复指南 -> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md),reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。 +> 前置条件:先阅读 [`../SKILL.md`](../SKILL.md) 的“评论能力入口”,添加评论参数细节见 [`lark-drive-add-comment.md`](lark-drive-add-comment.md),获取评论列表优先使用 [`lark-drive-list-comments.md`](lark-drive-list-comments.md),reaction 见 [`lark-drive-reactions.md`](lark-drive-reactions.md)。 ## 评论模式 @@ -16,14 +16,21 @@ ## 查询默认口径 -`drive file.comments list` 默认必须传 `is_solved:false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到要包含已解决评论,仍然按默认口径查询未解决评论。仅当用户明确要求包含已解决评论时,才可省略 `is_solved` 参数。 +优先使用 `drive +list-comments`,不要优先手写 `drive file.comments list`。shortcut 默认 `--solved-status false`,即仅查询未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论;仅当用户明确要求包含已解决评论时,才传 `--solved-status all`。只查已解决评论时传 `--solved-status true`。 ```bash # 默认查询:仅未解决评论 -lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx","is_solved":false}' +lark-cli drive +list-comments --url '' + +# 全部评论:包含已解决和未解决 +lark-cli drive +list-comments --url '' --solved-status all + +# 已解决评论 +lark-cli drive +list-comments --url '' --solved-status true + +# 裸 wiki token +lark-cli drive +list-comments --token '' --type wiki -# 包含已解决评论:仅当用户明确要求时使用 -lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"docx"}' ``` ## 评论卡片与统计 @@ -53,12 +60,13 @@ lark-cli drive file.comments list --params '{"file_token":"xxx","file_type":"doc ## batch_query 与 list - `drive file.comments batch_query` 用于已知评论 ID 后的批量查询,需要传入具体评论 ID 列表。 -- `drive file.comments list` 用于分页获取评论列表,适合统计评论总数、遍历所有评论、获取最新或最后 N 条评论等场景。 +- `drive +list-comments` 用于分页获取评论列表;如果要统计全量评论数、遍历包含已解决评论在内的所有评论、获取全量最新评论或最后 N 条评论,请先传 `--solved-status all` 并拉完所有分页。它会处理 URL、wiki token 和 token/type 匹配问题。 +- `drive file.comments list` 是原生命令。需要 shortcut 未暴露的字段时才使用。 ## 评论定位字段 -- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block),先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。 -- 其他文档类型暂不支持返回定位字段。 +- 需要根据评论定位到文档正文位置时(例如根据评论 review 文档、区分多处相同引用文本、把评论落点映射到 `docs +fetch` 的 block),先确认目标是 `file_type=docx`,再阅读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md),并使用 `drive +list-comments --need-relation`。 +- `--need-relation` 仅 docx 生效;其他文档类型会静默忽略。 ## 原生 API diff --git a/skills/lark-drive/references/lark-drive-list-comments.md b/skills/lark-drive/references/lark-drive-list-comments.md new file mode 100644 index 000000000..3732f8062 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-list-comments.md @@ -0,0 +1,106 @@ +# drive +list-comments + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和权限处理。 + +列出 doc/docx/sheet/file/slides/base(bitable) 的评论卡片。优先传 URL,shortcut 会自动识别类型;如果传 wiki URL 或 `--token --type wiki`,会先解析到真实文档。原生 `drive file.comments list` 仍保留用于需要特殊参数的场景。 + +## 命令 + +```bash +# 推荐:直接传 URL。默认只查未解决评论。 +lark-cli drive +list-comments \ + --url "https://example.larksuite.com/docx/" + +# 查询全部评论,包括已解决和未解决。 +lark-cli drive +list-comments \ + --url "https://example.larksuite.com/docx/" \ + --solved-status all + +# 查询已解决评论。 +lark-cli drive +list-comments \ + --url "https://example.larksuite.com/docx/" \ + --solved-status true + +# 只查全文评论或局部评论。 +lark-cli drive +list-comments \ + --url "https://example.larksuite.com/docx/" \ + --comment-scope whole + +lark-cli drive +list-comments \ + --url "https://example.larksuite.com/docx/" \ + --comment-scope partial + +# wiki URL 会自动解包。 +lark-cli drive +list-comments \ + --url "https://example.larksuite.com/wiki/" + +# 裸 wiki token 也支持,但必须显式声明 --type wiki。 +lark-cli drive +list-comments \ + --token "" \ + --type wiki + +# 裸真实 token 需要声明真实类型。 +lark-cli drive +list-comments \ + --token "" \ + --type docx \ + --page-size 100 + +# docx 需要评论定位关系时再带 need-relation;非 docx 会静默忽略。 +lark-cli drive +list-comments \ + --url "https://example.larksuite.com/docx/" \ + --need-relation + +# 分页续跑。 +lark-cli drive +list-comments \ + --url "https://example.larksuite.com/docx/" \ + --page-size 100 \ + --page-token "" + +# 预览请求链路,不发真实请求。 +lark-cli drive +list-comments \ + --url "https://example.larksuite.com/wiki/" \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 与 `--token` 二选一 | 推荐入口。支持 doc/docx/sheet/file/slides/base/bitable/wiki URL;wiki URL 会自动解析到真实文档。 | +| `--token` | 与 `--url` 二选一 | 裸 token 或 URL。裸 token 必须搭配 `--type`;wiki token 使用 `--type wiki`。 | +| `--type` | 裸 token 时必填 | `doc`、`docx`、`sheet`、`file`、`slides`、`bitable`、`base`、`wiki`。传 `base` 时,CLI 会按 `bitable` 类型处理。 | +| `--solved-status` | 否 | `false` / `true` / `all`,默认 `false`。`false` 查未解决评论;`true` 查已解决评论;`all` 查全部评论。 | +| `--comment-scope` | 否 | `all` / `whole` / `partial`,默认 `all`。`all` 查全部范围;`whole` 查全文评论;`partial` 查局部评论。 | +| `--need-reaction` | 否 | 是否返回评论卡片上的 reaction 数据;只有用户明确需要 reaction 时才带。 | +| `--need-relation` | 否 | docx 评论定位关系字段;仅 docx 生效,非 docx 静默忽略。需要定位正文时先读 [`lark-drive-comment-location.md`](lark-drive-comment-location.md)。 | +| `--page-size` | 否 | 默认 50,最大 100。 | +| `--page-token` | 否 | 分页游标;本 shortcut 不自动翻页,按返回的 `page_token` 继续请求下一页。 | +| `--user-id-type` | 否 | `user_id` / `union_id` / `open_id`,默认 `open_id`。 | + +## 行为说明 + +- 默认 `--solved-status false`,即默认只查未解决评论。即使用户说“所有评论”“全部评论”“把评论都列出来”,只要没有明确提到包含已解决评论,仍然按默认口径查询未解决评论;仅当用户明确要求包含已解决评论时,才传 `--solved-status all`。 +- `--comment-scope all` 查全部范围;`whole` 查全文评论;`partial` 查局部/选区评论。 +- URL 输入时不需要传 `--type`;如果 URL 类型和显式 `--type` 冲突,shortcut 会返回 validation error,建议移除 `--type`。 +- wiki 输入会自动解析到真实文档,再查询评论列表。JSON 输出不额外返回 wiki token 或 wiki node。 +- 输出中的 `items` 保留评论卡片字段,外层补充 `file_token`、`file_type`、`has_more`、`page_token`、`count`;`count` 是当前页返回的评论卡片数。 +- 如果需要批量按评论 ID 查询、获取更多回复、创建/编辑/删除回复,继续使用原生 `drive file.comments batch_query` 或 `drive file.comment.replys.*`。 + +## 输出 + +```json +{ + "file_token": "docx_token", + "file_type": "docx", + "items": [], + "has_more": false, + "page_token": "", + "count": 0 +} +``` + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令 +- [lark-drive-comments-guide](lark-drive-comments-guide.md) -- 评论统计、回复限制和原生 API 说明 +- [lark-drive-comment-location](lark-drive-comment-location.md) -- 使用 `need_relation` 定位 docx 正文 diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index 115c46bc7..4cef31fa8 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -1,9 +1,9 @@ # Drive CLI E2E Coverage ## Metrics -- Denominator: 31 leaf commands -- Covered: 10 -- Coverage: 32.3% +- Denominator: 32 leaf commands +- Covered: 11 +- Coverage: 34.4% ## Summary - TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`. @@ -12,7 +12,8 @@ - TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with `duplicate_remote_path`, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`. - TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API. - TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields. -- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`. +- TestDriveListCommentsDryRun_DocxDefaults / TestDriveListCommentsDryRun_WikiToken: dry-run coverage for `drive +list-comments`; asserts URL parsing to `files/:token/comments`, default `is_solved=false`, default omitted `is_whole`, `user_id_type=open_id`, and Wiki token orchestration (`get_node -> comments.list`) without live API calls. +- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up. - TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows. - TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs. - TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary. @@ -26,6 +27,7 @@ | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | | ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id !!` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` | +| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_DocxDefaults; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_WikiToken; drive_add_comment_workflow_test.go::TestDriveAddCommentMarkdownFileWorkflow | `--url`; `--token + --type wiki`; `--solved-status=false\|all`; `--comment-scope=all\|partial`; `--need-relation`; `--page-size`; `--user-id-type` | dry-run locks URL/token parsing, default unresolved filter, omitted all-scope filter, and Wiki unwrap request shape; opt-in live workflow verifies a created file comment can be listed back | | ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner | | ✕ | drive +delete | shortcut | | none | no primary delete workflow yet | | ✕ | drive +download | shortcut | | none | no file fixture workflow yet | diff --git a/tests/cli_e2e/drive/drive_add_comment_workflow_test.go b/tests/cli_e2e/drive/drive_add_comment_workflow_test.go index 1ea83326f..4b47fc8e5 100644 --- a/tests/cli_e2e/drive/drive_add_comment_workflow_test.go +++ b/tests/cli_e2e/drive/drive_add_comment_workflow_test.go @@ -81,4 +81,41 @@ func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) { if got := gjson.Get(commentResult.Stdout, "data.file_extension").String(); got != ".md" { t.Fatalf("data.file_extension=%q, want .md\nstdout:\n%s", got, commentResult.Stdout) } + + listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "drive", "+list-comments", + "--token", fileToken, + "--type", "file", + "--solved-status", "all", + "--page-size", "100", + }, + DefaultAs: "bot", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + return result == nil || result.ExitCode != 0 || !driveCommentListContainsID(result.Stdout, commentID) + }, + }) + require.NoError(t, err) + listResult.AssertExitCode(t, 0) + listResult.AssertStdoutStatus(t, true) + + if got := gjson.Get(listResult.Stdout, "data.file_token").String(); got != fileToken { + t.Fatalf("list data.file_token=%q, want %q\nstdout:\n%s", got, fileToken, listResult.Stdout) + } + if got := gjson.Get(listResult.Stdout, "data.file_type").String(); got != "file" { + t.Fatalf("list data.file_type=%q, want file\nstdout:\n%s", got, listResult.Stdout) + } + if !driveCommentListContainsID(listResult.Stdout, commentID) { + t.Fatalf("list comments did not include comment_id %q\nstdout:\n%s", commentID, listResult.Stdout) + } +} + +func driveCommentListContainsID(stdout, commentID string) bool { + for _, item := range gjson.Get(stdout, "data.items").Array() { + if item.Get("comment_id").String() == commentID { + return true + } + } + return false } diff --git a/tests/cli_e2e/drive/drive_list_comments_dryrun_test.go b/tests/cli_e2e/drive/drive_list_comments_dryrun_test.go new file mode 100644 index 000000000..0dc914fe9 --- /dev/null +++ b/tests/cli_e2e/drive/drive_list_comments_dryrun_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDriveListCommentsDryRun_DocxDefaults(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+list-comments", + "--url", "https://example.larksuite.com/docx/docxDryRunCommentList", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCommentList/comments" { + t.Fatalf("api.0.url=%q, want comments list\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.file_type").String(); got != "docx" { + t.Fatalf("api.0.params.file_type=%q, want docx\nstdout:\n%s", got, out) + } + isSolved := gjson.Get(out, "api.0.params.is_solved") + if !isSolved.Exists() || isSolved.Bool() { + t.Fatalf("api.0.params.is_solved=%v, want explicit false\nstdout:\n%s", isSolved.Value(), out) + } + if gjson.Get(out, "api.0.params.is_whole").Exists() { + t.Fatalf("api.0.params.is_whole should be omitted by default\nstdout:\n%s", out) + } + if got := gjson.Get(out, "api.0.params.page_size").Int(); got != 50 { + t.Fatalf("api.0.params.page_size=%d, want 50\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.user_id_type").String(); got != "open_id" { + t.Fatalf("api.0.params.user_id_type=%q, want open_id\nstdout:\n%s", got, out) + } +} + +func TestDriveListCommentsDryRun_WikiToken(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+list-comments", + "--token", "wikiDryRunCommentList", + "--type", "wiki", + "--solved-status", "all", + "--comment-scope", "partial", + "--need-relation", + "--page-size", "99", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "api.0.url").String(); got != "/open-apis/wiki/v2/spaces/get_node" { + t.Fatalf("api.0.url=%q, want wiki get_node\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.params.token").String(); got != "wikiDryRunCommentList" { + t.Fatalf("api.0.params.token=%q, want wikiDryRunCommentList\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files//comments" { + t.Fatalf("api.1.url=%q, want resolved comments list placeholder\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.1.params.file_type").String(); got != "" { + t.Fatalf("api.1.params.file_type=%q, want obj_type placeholder\nstdout:\n%s", got, out) + } + if gjson.Get(out, "api.1.params.is_solved").Exists() { + t.Fatalf("api.1.params.is_solved should be omitted for solved-status all\nstdout:\n%s", out) + } + isWhole := gjson.Get(out, "api.1.params.is_whole") + if !isWhole.Exists() || isWhole.Bool() { + t.Fatalf("api.1.params.is_whole=%v, want explicit false for partial\nstdout:\n%s", isWhole.Value(), out) + } + if got := gjson.Get(out, "api.1.params.need_relation").String(); got != "" { + t.Fatalf("api.1.params.need_relation=%q, want conditional placeholder\nstdout:\n%s", got, out) + } +}