diff --git a/shortcuts/drive/drive_list_comments.go b/shortcuts/drive/drive_list_comments.go new file mode 100644 index 000000000..9a277dd9c --- /dev/null +++ b/shortcuts/drive/drive_list_comments.go @@ -0,0 +1,641 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "io" + "sort" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// listCommentsPageSize is the page size used when listing file comments. +const listCommentsPageSize = 100 + +// listCommentsMaxPages caps pagination depth to prevent runaway loops on +// documents with very large comment histories. +const listCommentsMaxPages = 100 + +// DriveListComments lists comments on a docx with smart defaults: filter to +// unresolved + non-orphan anchors, ordered by anchor position, including +// replies and reactions. See https://github.com/larksuite/cli/issues/1111. +var DriveListComments = common.Shortcut{ + Service: "drive", + Command: "+list-comments", + Description: "List docx comments with smart defaults (unresolved + non-orphan + anchor-ordered + replies + reactions); wiki URLs are auto-unwrapped", + Risk: "read", + Scopes: []string{"docs:document.comment:read", "docx:document:readonly"}, + ConditionalScopes: []string{"wiki:node:retrieve"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + { + Name: "doc", + Desc: "document URL or token (docx URL, bare token + --type=docx, or wiki URL resolving to docx)", + Required: true, + }, + { + Name: "type", + Desc: "document type (required when --doc is a bare token; MVP supports docx only)", + Enum: []string{"docx", "wiki"}, + }, + { + Name: "include-orphaned", + Type: "bool", + Desc: "include comments whose anchor text was deleted/rewritten (default: hide, matching the Lark UI)", + }, + { + Name: "include-resolved", + Type: "bool", + Desc: "include resolved comments (default: hide, i.e. is_solved=false only)", + }, + { + Name: "no-reactions", + Type: "bool", + Desc: "do not include reaction details on replies (default: reactions are fetched)", + }, + { + Name: "order", + Desc: "result order: anchor (by position in document, default) | created (by create_time)", + Default: "anchor", + Enum: []string{"anchor", "created"}, + }, + }, + Validate: validateListComments, + DryRun: dryRunListComments, + Execute: executeListComments, +} + +// validateListComments enforces docx-only scope for MVP and checks that --doc +// is either a recognized URL or a bare token paired with --type. +func validateListComments(_ context.Context, runtime *common.RuntimeContext) error { + raw := strings.TrimSpace(runtime.Str("doc")) + if raw == "" { + return output.ErrValidation("--doc cannot be empty") + } + ref, ok := parseListCommentsDocRef(raw, runtime.Str("type")) + if !ok { + if strings.Contains(raw, "://") { + return output.ErrValidation("unsupported --doc input %q: use a docx URL, a bare token with --type=docx, or a wiki URL", raw) + } + if strings.TrimSpace(runtime.Str("type")) == "" { + return output.ErrValidation("--type is required when --doc is a bare token (allowed: docx, wiki)") + } + return output.ErrValidation("--type %q is not supported by drive +list-comments (MVP supports docx and wiki resolving to docx)", runtime.Str("type")) + } + if ref.Type != "docx" && ref.Type != "wiki" { + return output.ErrValidation("--doc must resolve to docx (got %q); sheet/slides/file/doc are not supported by drive +list-comments yet", ref.Type) + } + if v := strings.TrimSpace(runtime.Str("order")); v != "" && v != "anchor" && v != "created" { + return output.ErrValidation("--order must be one of: anchor, created (got %q)", v) + } + return nil +} + +// parseListCommentsDocRef recognizes a docx or wiki URL, or a bare token +// combined with --type. Returns (ref, true) on success. +func parseListCommentsDocRef(raw, docType string) (common.ResourceRef, bool) { + ref, ok := common.ParseResourceURL(raw) + if ok { + return ref, true + } + if strings.Contains(raw, "://") { + return common.ResourceRef{}, false + } + t := strings.TrimSpace(docType) + if t == "" { + return common.ResourceRef{}, false + } + if t != "docx" && t != "wiki" { + return common.ResourceRef{}, false + } + return common.ResourceRef{Type: t, Token: raw}, true +} + +func dryRunListComments(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + raw := strings.TrimSpace(runtime.Str("doc")) + ref, _ := parseListCommentsDocRef(raw, runtime.Str("type")) + isWiki := ref.Type == "wiki" + + dry := common.NewDryRunAPI() + stepCount := 0 + + if isWiki { + stepCount++ + dry.GET("/open-apis/wiki/v2/spaces/get_node"). + Desc(fmt.Sprintf("[%d] Resolve wiki node to underlying docx", stepCount)). + Params(map[string]interface{}{"token": ref.Token}) + } + + listToken := ref.Token + if isWiki { + listToken = "" + } + stepCount++ + listParams := map[string]interface{}{ + "file_type": "docx", + "page_size": listCommentsPageSize, + "need_relation": true, + } + if !runtime.Bool("include-resolved") { + listParams["is_solved"] = false + } + if !runtime.Bool("no-reactions") { + listParams["need_reaction"] = true + } + dry.GET(fmt.Sprintf("/open-apis/drive/v1/files/%s/comments", listToken)). + Desc(fmt.Sprintf("[%d] List comments (paginated until exhausted)", stepCount)). + Params(listParams) + + stepCount++ + dry.POST(fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", listToken)). + Desc(fmt.Sprintf("[%d] Fetch document XML with block IDs for anchor ordering", stepCount)). + Body(buildListCommentsFetchBody()) + + dry.Desc(fmt.Sprintf("%d-step orchestration: list comments with relation + fetch block IDs + order anchors", stepCount)) + return dry +} + +// commentItem holds a single comment card plus our derived anchor metadata. +type commentItem struct { + Raw map[string]interface{} + CommentID string + Quote string + IsWhole bool + IsSolved bool + CreateTime int64 + AnchorState string // "valid" | "structural" | "orphaned" + AnchorPosition int64 // block order in docs fetch XML; -1 when unknown/orphaned + AnchorBlockID string + LocationAccuracy string // relation_exact | parent_resource_exact | weak_inferred | content_deleted + ParentType string + ParentToken string + ContentDeleted bool +} + +func executeListComments(_ context.Context, runtime *common.RuntimeContext) error { + raw := strings.TrimSpace(runtime.Str("doc")) + ref, ok := parseListCommentsDocRef(raw, runtime.Str("type")) + if !ok { + // Validate should have caught this; keep guard for safety. + return output.ErrValidation("unable to parse --doc input %q", raw) + } + + // Step 1: Resolve wiki to docx token if needed. + fileToken := ref.Token + var wikiToken string + if ref.Type == "wiki" { + wikiToken = ref.Token + fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(wikiToken)) + data, err := runtime.CallAPI( + "GET", + "/open-apis/wiki/v2/spaces/get_node", + map[string]interface{}{"token": wikiToken}, + nil, + ) + if err != nil { + return err + } + node := common.GetMap(data, "node") + objType := common.GetString(node, "obj_type") + objToken := common.GetString(node, "obj_token") + if objType != "docx" { + return output.ErrValidation("wiki resolved to %q; drive +list-comments only supports docx (MVP)", objType) + } + if objToken == "" { + return output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned empty obj_token") + } + fileToken = objToken + fmt.Fprintf(runtime.IO().ErrOut, "Wiki resolved to docx: %s\n", common.MaskToken(fileToken)) + } + + // Step 2: Paginate through all comments. + includeResolved := runtime.Bool("include-resolved") + needReactions := !runtime.Bool("no-reactions") + allRaw, err := listAllComments(runtime, fileToken, includeResolved, needReactions) + if err != nil { + return err + } + + // Step 3: Fetch document XML with block IDs for stable anchor ordering. + docXML, err := fetchDocxXML(runtime, fileToken) + if err != nil { + return err + } + blockIndex := buildDocBlockIndex(docXML) + + // Step 4: Build commentItem list with relation-derived location metadata. + items := make([]commentItem, 0, len(allRaw)) + for _, r := range allRaw { + items = append(items, buildCommentItem(r, blockIndex)) + } + + // Step 5: Filter by include-orphaned. + includeOrphaned := runtime.Bool("include-orphaned") + filtered := items + if !includeOrphaned { + filtered = make([]commentItem, 0, len(items)) + for _, it := range items { + if it.AnchorState == "orphaned" { + continue + } + filtered = append(filtered, it) + } + } + + // Step 6: Sort. + orderKey := strings.TrimSpace(runtime.Str("order")) + if orderKey == "" { + orderKey = "anchor" + } + sortCommentItems(filtered, orderKey) + + // Step 7: Build output envelope. + outItems := make([]map[string]interface{}, 0, len(filtered)) + for _, it := range filtered { + m := cloneCommentMap(it.Raw) + m["anchor_state"] = it.AnchorState + m["anchor_position"] = it.AnchorPosition + m["location_accuracy"] = it.LocationAccuracy + m["content_deleted"] = it.ContentDeleted + if it.AnchorBlockID != "" { + m["anchor_block_id"] = it.AnchorBlockID + } + outItems = append(outItems, m) + } + + counts := countByAnchorState(items) + result := map[string]interface{}{ + "items": outItems, + "file_token": fileToken, + "counts": counts, + } + if wikiToken != "" { + result["wiki_token"] = wikiToken + } + + fmt.Fprintf(runtime.IO().ErrOut, + "Comments: %d total (valid=%d, structural=%d, orphaned=%d); returned %d\n", + counts["total"], counts["valid"], counts["structural"], counts["orphaned"], len(filtered)) + + runtime.OutFormatRaw(result, nil, func(w io.Writer) { + for i, it := range filtered { + fmt.Fprintf(w, "[%d] %s (%s, pos=%d) %s\n", + i+1, it.CommentID, it.AnchorState, it.AnchorPosition, truncateQuote(it.Quote, 60)) + } + }) + return nil +} + +// listAllComments paginates through all comment cards on a docx, returning +// the raw maps from the API. Pagination is bounded by listCommentsMaxPages. +func listAllComments(runtime *common.RuntimeContext, fileToken string, includeResolved, needReactions bool) ([]map[string]interface{}, error) { + encodedToken := validate.EncodePathSegment(fileToken) + apiPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/comments", encodedToken) + var pageToken string + var all []map[string]interface{} + for page := 0; page < listCommentsMaxPages; page++ { + params := map[string]interface{}{ + "file_type": "docx", + "page_size": listCommentsPageSize, + "need_relation": true, + } + if !includeResolved { + params["is_solved"] = false + } + if needReactions { + params["need_reaction"] = true + } + if pageToken != "" { + params["page_token"] = pageToken + } + data, err := runtime.CallAPI("GET", apiPath, params, nil) + if err != nil { + return nil, err + } + if rawItems, ok := data["items"].([]interface{}); ok { + for _, it := range rawItems { + if m, ok := it.(map[string]interface{}); ok { + all = append(all, m) + } + } + } + hasMore, _ := data["has_more"].(bool) + if !hasMore { + return all, nil + } + next, _ := data["page_token"].(string) + if next == "" || next == pageToken { + return all, nil + } + pageToken = next + } + return all, nil +} + +// fetchDocxXML returns document XML with block IDs exported for comment anchor +// ordering. The comment API supplies the anchor block ID via need_relation; the +// docs fetch call supplies the current document block order. +func fetchDocxXML(runtime *common.RuntimeContext, fileToken string) (string, error) { + apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/fetch", + validate.EncodePathSegment(fileToken)) + body := buildListCommentsFetchBody() + data, err := runtime.CallAPI("POST", apiPath, nil, body) + if err != nil { + return "", err + } + doc := common.GetMap(data, "document") + return common.GetString(doc, "content"), nil +} + +func buildListCommentsFetchBody() map[string]interface{} { + return map[string]interface{}{ + "format": "xml", + "export_option": map[string]interface{}{ + "export_block_id": true, + }, + } +} + +type docBlockIndex struct { + orderByBlockID map[string]int64 + blockByToken map[string]string +} + +func buildDocBlockIndex(content string) docBlockIndex { + idx := docBlockIndex{ + orderByBlockID: map[string]int64{}, + blockByToken: map[string]string{}, + } + decoder := xml.NewDecoder(strings.NewReader(content)) + var order int64 + for { + token, err := decoder.Token() + if err != nil { + break + } + start, ok := token.(xml.StartElement) + if !ok { + continue + } + blockID := firstXMLAttr(start, "id", "block_id") + if blockID == "" { + continue + } + if _, exists := idx.orderByBlockID[blockID]; !exists { + idx.orderByBlockID[blockID] = order + order++ + } + for _, attr := range start.Attr { + if attr.Value == "" { + continue + } + switch attr.Name.Local { + case "token", "spreadsheet_token", "base_token", "app_token", "whiteboard_token": + idx.blockByToken[attr.Value] = blockID + } + } + } + return idx +} + +func firstXMLAttr(start xml.StartElement, names ...string) string { + for _, want := range names { + for _, attr := range start.Attr { + if attr.Name.Local == want { + return attr.Value + } + } + } + return "" +} + +func (idx docBlockIndex) orderOfBlock(blockID string) int64 { + if blockID == "" { + return -1 + } + order, ok := idx.orderByBlockID[blockID] + if !ok { + return -1 + } + return order +} + +func (idx docBlockIndex) lookupEmbeddedToken(parentToken string) (string, int64, bool) { + for _, candidate := range parentTokenCandidates(parentToken) { + if blockID := idx.blockByToken[candidate]; blockID != "" { + return blockID, idx.orderOfBlock(blockID), true + } + } + return "", -1, false +} + +func parentTokenCandidates(parentToken string) []string { + parentToken = strings.TrimSpace(parentToken) + if parentToken == "" { + return nil + } + candidates := []string{parentToken} + if idx := strings.LastIndex(parentToken, "_tbl"); idx > 0 { + candidates = append(candidates, parentToken[:idx]) + } + if idx := strings.LastIndex(parentToken, "_"); idx > 0 { + candidates = append(candidates, parentToken[:idx]) + } + if idx := strings.Index(parentToken, "_"); idx > 0 { + candidates = append(candidates, parentToken[:idx]) + } + return candidates +} + +// extractCommentRelation parses the relation payload returned by +// need_relation=true. The nested relation.relation value is itself a JSON +// string, keyed by server object identity; positionInfo.blockID is the stable +// docx block anchor we need. +func extractCommentRelation(raw map[string]interface{}) (blockID string, contentDeleted bool, ok bool) { + relation := common.GetMap(raw, "relation") + if relation == nil { + return "", false, false + } + contentDeleted = common.GetBool(relation, "content_deleted") + relJSON := strings.TrimSpace(common.GetString(relation, "relation")) + if relJSON == "" { + return "", contentDeleted, false + } + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(relJSON), &parsed); err != nil { + return "", contentDeleted, false + } + blockID = findRelationBlockID(parsed) + return blockID, contentDeleted, blockID != "" +} + +func findRelationBlockID(v interface{}) string { + switch node := v.(type) { + case map[string]interface{}: + if pos, ok := node["positionInfo"].(map[string]interface{}); ok { + if blockID, _ := pos["blockID"].(string); blockID != "" { + return blockID + } + if blockID, _ := pos["block_id"].(string); blockID != "" { + return blockID + } + } + for _, child := range node { + if blockID := findRelationBlockID(child); blockID != "" { + return blockID + } + } + case []interface{}: + for _, child := range node { + if blockID := findRelationBlockID(child); blockID != "" { + return blockID + } + } + } + return "" +} + +// buildCommentItem parses an API comment card and computes relation-derived +// anchor_state + anchor_position. Position is current document block order, not +// a byte offset, and -1 means the location is unknown or deleted. +func buildCommentItem(raw map[string]interface{}, idx docBlockIndex) commentItem { + quote, _ := raw["quote"].(string) + isWhole, _ := raw["is_whole"].(bool) + isSolved, _ := raw["is_solved"].(bool) + commentID, _ := raw["comment_id"].(string) + parentType, _ := raw["parent_type"].(string) + parentToken, _ := raw["parent_token"].(string) + var createTime int64 + if v, ok := raw["create_time"].(float64); ok { + createTime = int64(v) + } + + item := commentItem{ + Raw: raw, + CommentID: commentID, + Quote: quote, + IsWhole: isWhole, + IsSolved: isSolved, + CreateTime: createTime, + AnchorPosition: -1, + LocationAccuracy: "weak_inferred", + ParentType: parentType, + ParentToken: parentToken, + } + + blockID, deleted, hasRelation := extractCommentRelation(raw) + item.ContentDeleted = deleted + if deleted { + item.AnchorState = "orphaned" + item.LocationAccuracy = "content_deleted" + item.AnchorBlockID = blockID + return item + } + if hasRelation { + item.AnchorBlockID = blockID + if order := idx.orderOfBlock(blockID); order >= 0 { + if parentType != "" { + item.AnchorState = "structural" + item.LocationAccuracy = "parent_resource_exact" + } else { + item.AnchorState = "valid" + item.LocationAccuracy = "relation_exact" + } + item.AnchorPosition = order + return item + } + } + if parentType != "" && parentToken != "" { + if blockID, order, ok := idx.lookupEmbeddedToken(parentToken); ok { + item.AnchorState = "structural" + item.AnchorBlockID = blockID + item.AnchorPosition = order + item.LocationAccuracy = "parent_resource_exact" + return item + } + } + if hasRelation { + item.AnchorState = "valid" + item.LocationAccuracy = "weak_inferred" + return item + } + if isWhole { + item.AnchorState = "valid" + item.AnchorPosition = 0 + item.LocationAccuracy = "whole_document" + return item + } + item.AnchorState = "valid" + return item +} + +// sortCommentItems sorts items in-place. Orphans always sort to the end. +// Within the non-orphan group: +// - order=anchor (default): by AnchorPosition ascending, unknown locations after anchored comments +// - order=created: by CreateTime ascending +func sortCommentItems(items []commentItem, orderKey string) { + sort.SliceStable(items, func(i, j int) bool { + a, b := items[i], items[j] + aOrphan := a.AnchorState == "orphaned" + bOrphan := b.AnchorState == "orphaned" + if aOrphan != bOrphan { + return !aOrphan + } + if orderKey == "created" { + return a.CreateTime < b.CreateTime + } + // anchor order + aUnknown := a.AnchorPosition < 0 + bUnknown := b.AnchorPosition < 0 + if aUnknown != bUnknown { + return !aUnknown + } + if a.AnchorPosition != b.AnchorPosition { + return a.AnchorPosition < b.AnchorPosition + } + // stable tiebreaker + return a.CreateTime < b.CreateTime + }) +} + +func countByAnchorState(items []commentItem) map[string]int { + out := map[string]int{ + "total": len(items), + "valid": 0, + "structural": 0, + "orphaned": 0, + } + for _, it := range items { + out[it.AnchorState]++ + } + return out +} + +func cloneCommentMap(m map[string]interface{}) map[string]interface{} { + if m == nil { + return nil + } + out := make(map[string]interface{}, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +func truncateQuote(s string, n int) string { + s = strings.Join(strings.Fields(s), " ") + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "..." +} diff --git a/shortcuts/drive/drive_list_comments_test.go b/shortcuts/drive/drive_list_comments_test.go new file mode 100644 index 000000000..1cb830629 --- /dev/null +++ b/shortcuts/drive/drive_list_comments_test.go @@ -0,0 +1,666 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +// --- parseListCommentsDocRef --- + +func TestParseListCommentsDocRef_DocxURL(t *testing.T) { + t.Parallel() + ref, ok := parseListCommentsDocRef("https://example.feishu.cn/docx/doxAbc123", "") + if !ok { + t.Fatal("expected ok=true for docx URL") + } + if ref.Type != "docx" || ref.Token != "doxAbc123" { + t.Fatalf("got %+v", ref) + } +} + +func TestParseListCommentsDocRef_WikiURL(t *testing.T) { + t.Parallel() + ref, ok := parseListCommentsDocRef("https://example.feishu.cn/wiki/wikXyz789", "") + if !ok { + t.Fatal("expected ok=true for wiki URL") + } + if ref.Type != "wiki" || ref.Token != "wikXyz789" { + t.Fatalf("got %+v", ref) + } +} + +func TestParseListCommentsDocRef_BareTokenRequiresType(t *testing.T) { + t.Parallel() + _, ok := parseListCommentsDocRef("doxAbc123", "") + if ok { + t.Fatal("expected ok=false for bare token without --type") + } + ref, ok := parseListCommentsDocRef("doxAbc123", "docx") + if !ok { + t.Fatal("expected ok=true for bare token with --type=docx") + } + if ref.Type != "docx" || ref.Token != "doxAbc123" { + t.Fatalf("got %+v", ref) + } +} + +func TestParseListCommentsDocRef_RejectsUnsupportedType(t *testing.T) { + t.Parallel() + _, ok := parseListCommentsDocRef("token", "sheet") + if ok { + t.Fatal("expected ok=false for --type=sheet (not in MVP)") + } +} + +// --- validateListComments --- + +func newListCommentsCmd(t *testing.T, flags map[string]string) *cobra.Command { + t.Helper() + cmd := &cobra.Command{Use: "drive +list-comments"} + cmd.Flags().String("doc", "", "") + cmd.Flags().String("type", "", "") + cmd.Flags().Bool("include-orphaned", false, "") + cmd.Flags().Bool("include-resolved", false, "") + cmd.Flags().Bool("no-reactions", false, "") + cmd.Flags().String("order", "anchor", "") + for k, v := range flags { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatalf("set --%s=%q: %v", k, v, err) + } + } + return cmd +} + +func TestValidateListComments_EmptyDoc(t *testing.T) { + t.Parallel() + cmd := newListCommentsCmd(t, nil) + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + err := validateListComments(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--doc cannot be empty") { + t.Fatalf("err = %v, want empty-doc error", err) + } +} + +func TestValidateListComments_UnsupportedURL(t *testing.T) { + t.Parallel() + cmd := newListCommentsCmd(t, map[string]string{"doc": "https://example.feishu.cn/sheets/shtAbc"}) + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + err := validateListComments(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "must resolve to docx") { + t.Fatalf("err = %v, want docx-only error", err) + } +} + +func TestValidateListComments_BareTokenWithoutType(t *testing.T) { + t.Parallel() + cmd := newListCommentsCmd(t, map[string]string{"doc": "doxAbc123"}) + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + err := validateListComments(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "--type is required") { + t.Fatalf("err = %v, want missing-type error", err) + } +} + +func TestValidateListComments_DocxURLAccepted(t *testing.T) { + t.Parallel() + cmd := newListCommentsCmd(t, map[string]string{"doc": "https://example.feishu.cn/docx/doxAbc123"}) + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + if err := validateListComments(context.Background(), runtime); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateListComments_WikiURLAccepted(t *testing.T) { + t.Parallel() + cmd := newListCommentsCmd(t, map[string]string{"doc": "https://example.feishu.cn/wiki/wikAbc"}) + runtime := common.TestNewRuntimeContext(cmd, &core.CliConfig{}) + if err := validateListComments(context.Background(), runtime); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- relation / block location helpers --- + +func TestExtractRelationBlockID(t *testing.T) { + t.Parallel() + raw := map[string]interface{}{ + "relation": map[string]interface{}{ + "content_deleted": false, + "relation": `{"22-dox":{"objType":22,"positionInfo":{"blockID":"blk_123"}}}`, + }, + } + blockID, deleted, ok := extractCommentRelation(raw) + if !ok { + t.Fatal("expected relation to parse") + } + if deleted { + t.Fatal("content_deleted should be false") + } + if blockID != "blk_123" { + t.Fatalf("blockID = %q, want blk_123", blockID) + } +} + +func TestExtractRelationBlockID_MalformedRelationDoesNotDelete(t *testing.T) { + t.Parallel() + raw := map[string]interface{}{ + "relation": map[string]interface{}{ + "content_deleted": true, + "relation": `{not-json`, + }, + } + blockID, deleted, ok := extractCommentRelation(raw) + if ok { + t.Fatal("malformed relation JSON should not report a parsed block id") + } + if !deleted { + t.Fatal("content_deleted should still be honored when relation JSON is malformed") + } + if blockID != "" { + t.Fatalf("blockID = %q, want empty", blockID) + } +} + +func TestBuildDocBlockIndex_OrdersBlockIDsAndEmbeddedTokens(t *testing.T) { + t.Parallel() + xml := ` + A + + B + ` + idx := buildDocBlockIndex(xml) + if got := idx.orderOfBlock("blk_a"); got != 0 { + t.Fatalf("blk_a order = %d, want 0", got) + } + if got := idx.orderOfBlock("blk_b"); got != 2 { + t.Fatalf("blk_b order = %d, want 2", got) + } + blockID, order, ok := idx.lookupEmbeddedToken("wbd_123") + if !ok { + t.Fatal("expected embedded token lookup to succeed") + } + if blockID != "blk_whiteboard" || order != 1 { + t.Fatalf("embedded lookup = (%q,%d), want (blk_whiteboard,1)", blockID, order) + } +} + +func TestBuildCommentItem_ContentDeletedIsOrphaned(t *testing.T) { + t.Parallel() + raw := map[string]interface{}{ + "comment_id": "c_deleted", + "relation": map[string]interface{}{ + "content_deleted": true, + "relation": `{"22-dox":{"positionInfo":{"blockID":"blk_deleted"}}}`, + }, + } + idx := buildDocBlockIndex(`deleted anchor gone`) + it := buildCommentItem(raw, idx) + if it.AnchorState != "orphaned" || it.LocationAccuracy != "content_deleted" { + t.Fatalf("got state=%q accuracy=%q, want orphaned/content_deleted", it.AnchorState, it.LocationAccuracy) + } +} + +func TestBuildCommentItem_RelationExact(t *testing.T) { + t.Parallel() + raw := map[string]interface{}{ + "comment_id": "c_exact", + "create_time": float64(1700000000), + "relation": map[string]interface{}{ + "content_deleted": false, + "relation": `{"22-dox":{"positionInfo":{"blockID":"blk_a"}}}`, + }, + } + idx := buildDocBlockIndex(`A`) + it := buildCommentItem(raw, idx) + if it.AnchorState != "valid" || it.LocationAccuracy != "relation_exact" { + t.Fatalf("got state=%q accuracy=%q, want valid/relation_exact", it.AnchorState, it.LocationAccuracy) + } + if it.AnchorBlockID != "blk_a" || it.AnchorPosition != 0 { + t.Fatalf("got block=%q pos=%d, want blk_a/0", it.AnchorBlockID, it.AnchorPosition) + } +} + +func TestBuildCommentItem_ParentResourceExact(t *testing.T) { + t.Parallel() + raw := map[string]interface{}{ + "comment_id": "c_embed", + "parent_type": "WHITEBOARD_BLOCK", + "parent_token": "wbd_123", + } + idx := buildDocBlockIndex(``) + it := buildCommentItem(raw, idx) + if it.AnchorState != "structural" || it.LocationAccuracy != "parent_resource_exact" { + t.Fatalf("got state=%q accuracy=%q, want structural/parent_resource_exact", it.AnchorState, it.LocationAccuracy) + } + if it.AnchorBlockID != "blk_whiteboard" || it.AnchorPosition != 0 { + t.Fatalf("got block=%q pos=%d, want blk_whiteboard/0", it.AnchorBlockID, it.AnchorPosition) + } +} + +func TestBuildCommentItem_ParentResourceTokenUsesLastUnderscore(t *testing.T) { + t.Parallel() + raw := map[string]interface{}{ + "comment_id": "c_sheet_parent", + "parent_type": "SHEET_BLOCK", + "parent_token": "sht_token_123_sheet1", + } + idx := buildDocBlockIndex(``) + it := buildCommentItem(raw, idx) + if it.AnchorState != "structural" || it.LocationAccuracy != "parent_resource_exact" { + t.Fatalf("got state=%q accuracy=%q, want structural/parent_resource_exact", it.AnchorState, it.LocationAccuracy) + } + if it.AnchorBlockID != "blk_sheet" || it.AnchorPosition != 0 { + t.Fatalf("got block=%q pos=%d, want blk_sheet/0", it.AnchorBlockID, it.AnchorPosition) + } +} + +func TestBuildCommentItem_EmbeddedRelationUsesParentResourceAccuracy(t *testing.T) { + t.Parallel() + raw := map[string]interface{}{ + "comment_id": "c_sheet", + "parent_type": "SHEET_BLOCK", + "parent_token": "sht_123_sheet1", + "relation": map[string]interface{}{ + "content_deleted": false, + "relation": `{"22-dox":{"positionInfo":{"blockID":"blk_sheet"}}}`, + }, + } + idx := buildDocBlockIndex(``) + it := buildCommentItem(raw, idx) + if it.AnchorState != "structural" || it.LocationAccuracy != "parent_resource_exact" { + t.Fatalf("got state=%q accuracy=%q, want structural/parent_resource_exact", it.AnchorState, it.LocationAccuracy) + } + if it.AnchorBlockID != "blk_sheet" || it.AnchorPosition != 0 { + t.Fatalf("got block=%q pos=%d, want blk_sheet/0", it.AnchorBlockID, it.AnchorPosition) + } +} + +// --- sortCommentItems --- + +func TestSortCommentItems_OrphansAtEnd(t *testing.T) { + t.Parallel() + items := []commentItem{ + {CommentID: "a", AnchorState: "valid", AnchorPosition: 2, CreateTime: 1}, + {CommentID: "b", AnchorState: "orphaned", AnchorPosition: -1, CreateTime: 2}, + {CommentID: "c", AnchorState: "valid", AnchorPosition: 0, CreateTime: 3}, + {CommentID: "d", AnchorState: "structural", AnchorPosition: 1, CreateTime: 4}, + } + sortCommentItems(items, "anchor") + gotIDs := make([]string, len(items)) + for i, it := range items { + gotIDs[i] = it.CommentID + } + // Expected: c (0), d (1 structural), a (2), b (orphan) + wantIDs := []string{"c", "d", "a", "b"} + for i, want := range wantIDs { + if gotIDs[i] != want { + t.Fatalf("position %d: got %q want %q (full: %v)", i, gotIDs[i], want, gotIDs) + } + } +} + +func TestSortCommentItems_UnknownLocationsAfterAnchoredBeforeOrphans(t *testing.T) { + t.Parallel() + items := []commentItem{ + {CommentID: "a", AnchorState: "valid", AnchorPosition: -1, CreateTime: 1}, + {CommentID: "b", AnchorState: "valid", AnchorPosition: 2, CreateTime: 2}, + {CommentID: "c", AnchorState: "orphaned", AnchorPosition: -1, CreateTime: 3}, + {CommentID: "d", AnchorState: "valid", AnchorPosition: 1, CreateTime: 4}, + } + sortCommentItems(items, "anchor") + gotIDs := make([]string, len(items)) + for i, it := range items { + gotIDs[i] = it.CommentID + } + wantIDs := []string{"d", "b", "a", "c"} + for i, want := range wantIDs { + if gotIDs[i] != want { + t.Fatalf("position %d: got %q want %q (full: %v)", i, gotIDs[i], want, gotIDs) + } + } +} + +func TestSortCommentItems_CreatedOrder(t *testing.T) { + t.Parallel() + items := []commentItem{ + {CommentID: "a", AnchorState: "valid", AnchorPosition: 100, CreateTime: 3}, + {CommentID: "b", AnchorState: "valid", AnchorPosition: 50, CreateTime: 1}, + {CommentID: "c", AnchorState: "orphaned", AnchorPosition: -1, CreateTime: 2}, + } + sortCommentItems(items, "created") + // Expected: b (1), a (3), c (orphan at end despite time=2) + gotIDs := make([]string, len(items)) + for i, it := range items { + gotIDs[i] = it.CommentID + } + wantIDs := []string{"b", "a", "c"} + for i, want := range wantIDs { + if gotIDs[i] != want { + t.Fatalf("position %d: got %q want %q (full: %v)", i, gotIDs[i], want, gotIDs) + } + } +} + +// --- DryRun --- + +func TestDryRunListComments_DocxURL(t *testing.T) { + t.Parallel() + cmd := newListCommentsCmd(t, map[string]string{"doc": "https://example.feishu.cn/docx/doxAbc"}) + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) + dry := DriveListComments.DryRun(context.Background(), runtime) + if dry == nil { + t.Fatal("DryRun returned nil") + } + data, err := json.Marshal(dry) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got struct { + API []struct { + URL string `json:"url"` + Method string `json:"method"` + Params map[string]interface{} `json:"params"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(data, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + // Expect 2 calls (no wiki resolution): list comments, fetch doc. + if len(got.API) != 2 { + t.Fatalf("expected 2 API calls, got %d (%v)", len(got.API), got.API) + } + if !strings.Contains(got.API[0].URL, "/comments") { + t.Fatalf("first call should hit comments endpoint: %q", got.API[0].URL) + } + if got.API[0].Params["is_solved"] != false { + t.Fatalf("default should include is_solved=false: %#v", got.API[0].Params) + } + if got.API[0].Params["need_reaction"] != true { + t.Fatalf("default should include need_reaction=true: %#v", got.API[0].Params) + } + if got.API[0].Params["need_relation"] != true { + t.Fatalf("default should include need_relation=true: %#v", got.API[0].Params) + } + if !strings.Contains(got.API[1].URL, "/fetch") { + t.Fatalf("second call should hit fetch endpoint: %q", got.API[1].URL) + } + if got.API[1].Body["format"] != "xml" { + t.Fatalf("fetch should use format=xml: %#v", got.API[1].Body) + } + exportOption, _ := got.API[1].Body["export_option"].(map[string]interface{}) + if exportOption["export_block_id"] != true { + t.Fatalf("fetch should request export_block_id=true: %#v", got.API[1].Body) + } +} + +func TestDryRunListComments_WikiURL_AddsResolveStep(t *testing.T) { + t.Parallel() + cmd := newListCommentsCmd(t, map[string]string{"doc": "https://example.feishu.cn/wiki/wikAbc"}) + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) + dry := DriveListComments.DryRun(context.Background(), runtime) + data, _ := json.Marshal(dry) + var got struct { + API []struct { + URL string `json:"url"` + } `json:"api"` + } + _ = json.Unmarshal(data, &got) + if len(got.API) != 3 { + t.Fatalf("expected 3 API calls with wiki, got %d", len(got.API)) + } + if !strings.Contains(got.API[0].URL, "/wiki/v2/spaces/get_node") { + t.Fatalf("first call should resolve wiki: %q", got.API[0].URL) + } +} + +func TestDryRunListComments_IncludeResolvedDropsFilter(t *testing.T) { + t.Parallel() + cmd := newListCommentsCmd(t, map[string]string{ + "doc": "https://example.feishu.cn/docx/doxAbc", + "include-resolved": "true", + }) + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) + dry := DriveListComments.DryRun(context.Background(), runtime) + data, _ := json.Marshal(dry) + var got struct { + API []struct { + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal(data, &got) + if _, ok := got.API[0].Params["is_solved"]; ok { + t.Fatalf("include-resolved=true should omit is_solved: %#v", got.API[0].Params) + } +} + +func TestDryRunListComments_NoReactionsDropsParam(t *testing.T) { + t.Parallel() + cmd := newListCommentsCmd(t, map[string]string{ + "doc": "https://example.feishu.cn/docx/doxAbc", + "no-reactions": "true", + }) + runtime := common.TestNewRuntimeContextWithCtx(context.Background(), cmd, nil) + dry := DriveListComments.DryRun(context.Background(), runtime) + data, _ := json.Marshal(dry) + var got struct { + API []struct { + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal(data, &got) + if _, ok := got.API[0].Params["need_reaction"]; ok { + t.Fatalf("no-reactions=true should omit need_reaction: %#v", got.API[0].Params) + } +} + +// --- End-to-end execution with httpmock --- + +func TestDriveListComments_E2E_FiltersOrphanedByDefault(t *testing.T) { + cfg := &core.CliConfig{AppID: "drive-listcomments-e2e", AppSecret: "test-secret", Brand: core.BrandFeishu} + f, stdout, _, reg := cmdutil.TestFactory(t, cfg) + + // 4 comments: two relation-exact anchors, one embedded-resource anchor, + // and one content_deleted orphan. + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/doxTest/comments", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []map[string]interface{}{ + { + "comment_id": "later", + "quote": "later block", + "is_whole": false, + "is_solved": false, + "create_time": float64(1700000001), + "relation": map[string]interface{}{ + "content_deleted": false, + "relation": `{"22-doxTest":{"positionInfo":{"blockID":"blk_b"}}}`, + }, + "reply_list": map[string]interface{}{"replies": []interface{}{}}, + }, + { + "comment_id": "deleted", + "quote": "deleted anchor", + "is_whole": false, + "is_solved": false, + "create_time": float64(1700000002), + "relation": map[string]interface{}{ + "content_deleted": true, + "relation": `{"22-doxTest":{"positionInfo":{"blockID":"blk_deleted"}}}`, + }, + "reply_list": map[string]interface{}{"replies": []interface{}{}}, + }, + { + "comment_id": "embed", + "quote": "画板节点文本", + "is_whole": false, + "is_solved": false, + "create_time": float64(1700000003), + "parent_type": "WHITEBOARD_BLOCK", + "parent_token": "wbd_123", + "reply_list": map[string]interface{}{"replies": []interface{}{}}, + }, + { + "comment_id": "earlier", + "quote": "first block", + "is_whole": false, + "is_solved": false, + "create_time": float64(1700000004), + "relation": map[string]interface{}{ + "content_deleted": false, + "relation": `{"22-doxTest":{"positionInfo":{"blockID":"blk_a"}}}`, + }, + "reply_list": map[string]interface{}{"replies": []interface{}{}}, + }, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/doxTest/fetch", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "document": map[string]interface{}{ + "content": `firstlater`, + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveListComments, []string{ + "+list-comments", + "--doc", "doxTest", + "--type", "docx", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + + out := decodeDriveEnvelope(t, stdout) + items, ok := out["items"].([]interface{}) + if !ok { + t.Fatalf("missing items array: %#v", out) + } + if len(items) != 3 { + t.Fatalf("expected 3 items after orphan filter, got %d", len(items)) + } + gotIDs := make([]string, 0, len(items)) + for _, raw := range items { + item, _ := raw.(map[string]interface{}) + gotIDs = append(gotIDs, item["comment_id"].(string)) + } + wantIDs := []string{"earlier", "embed", "later"} + for i, want := range wantIDs { + if gotIDs[i] != want { + t.Fatalf("item order = %v, want %v", gotIDs, wantIDs) + } + } + embed, _ := items[1].(map[string]interface{}) + if embed["anchor_block_id"] != "blk_whiteboard" { + t.Fatalf("embedded anchor_block_id = %#v, want blk_whiteboard", embed["anchor_block_id"]) + } + if embed["location_accuracy"] != "parent_resource_exact" { + t.Fatalf("embedded location_accuracy = %#v, want parent_resource_exact", embed["location_accuracy"]) + } + counts, _ := out["counts"].(map[string]interface{}) + if counts["total"] != float64(4) { + t.Fatalf("counts.total = %#v, want 4", counts["total"]) + } + if counts["valid"] != float64(2) { + t.Fatalf("counts.valid = %#v, want 2", counts["valid"]) + } + if counts["structural"] != float64(1) { + t.Fatalf("counts.structural = %#v, want 1", counts["structural"]) + } + if counts["orphaned"] != float64(1) { + t.Fatalf("counts.orphaned = %#v, want 1", counts["orphaned"]) + } +} + +func TestDriveListComments_E2E_IncludeOrphanedKeepsAll(t *testing.T) { + cfg := &core.CliConfig{AppID: "drive-listcomments-include-orphan", AppSecret: "test-secret", Brand: core.BrandFeishu} + f, stdout, _, reg := cmdutil.TestFactory(t, cfg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/doxTest2/comments", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "has_more": false, + "items": []map[string]interface{}{ + { + "comment_id": "v1", + "quote": "硬件", + "is_whole": false, + "create_time": float64(1), + "relation": map[string]interface{}{ + "content_deleted": false, + "relation": `{"22-doxTest2":{"positionInfo":{"blockID":"blk_a"}}}`, + }, + }, + { + "comment_id": "o1", + "quote": "missing", + "is_whole": false, + "create_time": float64(2), + "relation": map[string]interface{}{ + "content_deleted": true, + "relation": `{"22-doxTest2":{"positionInfo":{"blockID":"blk_missing"}}}`, + }, + }, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/doxTest2/fetch", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "document": map[string]interface{}{ + "content": `硬件 only`, + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveListComments, []string{ + "+list-comments", + "--doc", "doxTest2", + "--type", "docx", + "--include-orphaned", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("expected success, got: %v", err) + } + + out := decodeDriveEnvelope(t, stdout) + items, _ := out["items"].([]interface{}) + if len(items) != 2 { + t.Fatalf("expected 2 items with --include-orphaned, got %d", len(items)) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index 3da5fdf58..c62172047 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -34,5 +34,6 @@ func Shortcuts() []common.Shortcut { DriveSecureLabelUpdate, DriveSearch, DriveInspect, + DriveListComments, } } diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index a38a5c0f4..7b26aa113 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -37,6 +37,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+secure-label-update", "+search", "+inspect", + "+list-comments", } if len(got) != len(want) { diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index 1da102998..da2e2fbad 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`. @@ -14,6 +14,7 @@ - TestDriveAddCommentDryRun_File: dry-run coverage for `drive +add-comment` on supported Drive file targets; pins the `metas.batch_query -> files/:token/new_comments` request chain, `file_type=file`, and the required placeholder `anchor.block_id`. - TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for the same path, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`. - 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. +- TestDriveListCommentsDryRun_RequestsRelationAndBlockIDs / TestDriveListCommentsWorkflow: dry-run coverage for `drive +list-comments` plus an opt-in live docx workflow; pins `need_relation=true` on comment listing, `export_block_id=true` on docs fetch, and relation-exact block mapping after creating a real comment. - TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata 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. - TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary. @@ -32,6 +33,7 @@ | ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata | `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir` | dry-run only; no live export workflow yet | | ✕ | drive +export-download | shortcut | | none | no export-download workflow yet | | ✕ | drive +import | shortcut | | none | no import workflow yet | +| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_RequestsRelationAndBlockIDs + drive_list_comments_workflow_test.go::TestDriveListCommentsWorkflow | `--doc` docx URL; `need_relation=true`; docs fetch `export_block_id=true`; opt-in live docx comment round-trip | dry-run runs in CI; live workflow gated by `LARK_DRIVE_LIST_COMMENTS_E2E=1`; unit/httpmock E2E cover relation/content_deleted sorting and filtering | | ✕ | drive +move | shortcut | | none | no move workflow yet | | ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery | | ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status | 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..b3df0a187 --- /dev/null +++ b/tests/cli_e2e/drive/drive_list_comments_dryrun_test.go @@ -0,0 +1,42 @@ +// 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_RequestsRelationAndBlockIDs(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", + "--doc", "https://example.larksuite.com/docx/doxDryRunComments", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + require.Equal(t, "/open-apis/drive/v1/files/doxDryRunComments/comments", gjson.Get(out, "api.0.url").String(), "stdout:\n%s", out) + require.True(t, gjson.Get(out, "api.0.params.need_relation").Bool(), "stdout:\n%s", out) + require.True(t, gjson.Get(out, "api.0.params.need_reaction").Bool(), "stdout:\n%s", out) + require.False(t, gjson.Get(out, "api.0.params.is_solved").Bool(), "stdout:\n%s", out) + + require.Equal(t, "/open-apis/docs_ai/v1/documents/doxDryRunComments/fetch", gjson.Get(out, "api.1.url").String(), "stdout:\n%s", out) + require.Equal(t, "xml", gjson.Get(out, "api.1.body.format").String(), "stdout:\n%s", out) + require.True(t, gjson.Get(out, "api.1.body.export_option.export_block_id").Bool(), "stdout:\n%s", out) +} diff --git a/tests/cli_e2e/drive/drive_list_comments_workflow_test.go b/tests/cli_e2e/drive/drive_list_comments_workflow_test.go new file mode 100644 index 000000000..9a56b4fec --- /dev/null +++ b/tests/cli_e2e/drive/drive_list_comments_workflow_test.go @@ -0,0 +1,200 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "encoding/json" + "encoding/xml" + "fmt" + "os" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDriveListCommentsWorkflow(t *testing.T) { + if os.Getenv("LARK_DRIVE_LIST_COMMENTS_E2E") == "" { + t.Skip("set LARK_DRIVE_LIST_COMMENTS_E2E=1 to run the docx list-comments live workflow") + } + clie2e.SkipWithoutUserToken(t) + + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + anchorText := "lark-cli-e2e-list-comments-anchor-" + suffix + commentText := "please review " + suffix + + createResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "docs", "+create", + "--api-version", "v2", + "--doc-format", "xml", + "--content", fmt.Sprintf("%s", anchorText), + }, + DefaultAs: "user", + }, clie2e.RetryOptions{}) + require.NoError(t, err) + createResult.AssertExitCode(t, 0) + + docToken := firstGJSON(createResult.Stdout, + "data.document.document_id", + "document.document_id", + "data.document_id", + ) + require.NotEmpty(t, docToken, "stdout:\n%s", createResult.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{ + "drive", "+delete", + "--file-token", docToken, + "--type", "docx", + }, + DefaultAs: "user", + Yes: true, + }) + clie2e.ReportCleanupFailure(parentT, "delete list-comments docx "+docToken, deleteResult, deleteErr) + }) + + fetchResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "docs", "+fetch", + "--api-version", "v2", + "--doc", docToken, + "--doc-format", "xml", + "--detail", "with-ids", + }, + DefaultAs: "user", + }, clie2e.RetryOptions{}) + require.NoError(t, err) + fetchResult.AssertExitCode(t, 0) + + docXML := gjson.Get(fetchResult.Stdout, "data.document.content").String() + require.NotEmpty(t, docXML, "stdout:\n%s", fetchResult.Stdout) + blockID := firstCommentableBlockID(t, docXML, docToken) + require.NotEmpty(t, blockID, "doc XML:\n%s", docXML) + + commentContent := mustCommentContentJSON(t, commentText) + commentResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "drive", "+add-comment", + "--doc", docToken, + "--type", "docx", + "--block-id", blockID, + "--content", commentContent, + }, + DefaultAs: "user", + }, clie2e.RetryOptions{}) + require.NoError(t, err) + commentResult.AssertExitCode(t, 0) + commentResult.AssertStdoutStatus(t, true) + + commentID := gjson.Get(commentResult.Stdout, "data.comment_id").String() + require.NotEmpty(t, commentID, "stdout:\n%s", commentResult.Stdout) + + listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "drive", "+list-comments", + "--doc", docToken, + "--type", "docx", + "--include-resolved", + "--include-orphaned", + }, + DefaultAs: "user", + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + return result == nil || result.ExitCode != 0 || !strings.Contains(result.Stdout, commentID) + }, + }) + require.NoError(t, err) + listResult.AssertExitCode(t, 0) + + item := findCommentItem(t, listResult.Stdout, commentID) + require.Equal(t, "valid", item.Get("anchor_state").String(), "stdout:\n%s", listResult.Stdout) + require.Equal(t, "relation_exact", item.Get("location_accuracy").String(), "stdout:\n%s", listResult.Stdout) + require.Equal(t, blockID, item.Get("anchor_block_id").String(), "stdout:\n%s", listResult.Stdout) + require.False(t, item.Get("content_deleted").Bool(), "stdout:\n%s", listResult.Stdout) + require.True(t, item.Get("relation.relation").Exists(), "stdout:\n%s", listResult.Stdout) +} + +func firstGJSON(raw string, paths ...string) string { + for _, path := range paths { + if value := strings.TrimSpace(gjson.Get(raw, path).String()); value != "" { + return value + } + } + return "" +} + +func firstCommentableBlockID(t *testing.T, content string, docToken string) string { + t.Helper() + + decoder := xml.NewDecoder(strings.NewReader(content)) + var fallback string + for { + token, err := decoder.Token() + if err != nil { + break + } + start, ok := token.(xml.StartElement) + if !ok { + continue + } + blockID := firstXMLAttrValue(start, "id", "block_id") + if blockID == "" { + continue + } + if fallback == "" { + fallback = blockID + } + if blockID != docToken { + return blockID + } + } + return fallback +} + +func firstXMLAttrValue(start xml.StartElement, names ...string) string { + for _, name := range names { + for _, attr := range start.Attr { + if attr.Name.Local == name { + return attr.Value + } + } + } + return "" +} + +func mustCommentContentJSON(t *testing.T, text string) string { + t.Helper() + + payload := []map[string]string{{"type": "text", "text": text}} + raw, err := json.Marshal(payload) + require.NoError(t, err) + return string(raw) +} + +func findCommentItem(t *testing.T, stdout string, commentID string) gjson.Result { + t.Helper() + + items := gjson.Get(stdout, "data.items").Array() + require.NotEmpty(t, items, "stdout:\n%s", stdout) + for _, item := range items { + if item.Get("comment_id").String() == commentID { + return item + } + } + t.Fatalf("comment %s not found in list output:\n%s", commentID, stdout) + return gjson.Result{} +}