diff --git a/shortcuts/drive/drive_add_comment.go b/shortcuts/drive/drive_add_comment.go index 41899054d..b40ca959d 100644 --- a/shortcuts/drive/drive_add_comment.go +++ b/shortcuts/drive/drive_add_comment.go @@ -47,6 +47,34 @@ const defaultLocateDocLimit = 10 // with `drive file.comments create_v2` against a fresh docx. const maxCommentTotalRunes = 10000 +// The file comment API treats supported Drive file comments as full-file +// comments in the UI, but currently rejects an empty anchor.block_id for file +// targets. TODO: remove this placeholder after the API accepts omitting +// anchor.block_id for file full comments. +const fileFullCommentAnchorBlockID = "test" + +// File comments are enabled only for extensions verified to render correctly in +// the Lark file preview comment UI. Keep this list conservative: PDF, docx, and +// xlsx currently accept the API request but display poorly in the page. +var supportedFileCommentExtensions = []string{ + ".md", + ".txt", + ".json", + ".csv", + ".go", + ".js", + ".py", + ".pptx", + ".png", + ".jpg", + ".jpeg", + ".zip", + ".mp3", + ".mp4", +} + +var supportedFileCommentExtensionSet = newSupportedFileCommentExtensionSet(supportedFileCommentExtensions) + type commentDocRef struct { Kind string Token string @@ -93,17 +121,18 @@ const ( var DriveAddComment = common.Shortcut{ Service: "drive", Command: "+add-comment", - Description: "Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides", + Description: "Add a comment to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only", Risk: "write", Scopes: []string{ + "drive:drive.metadata:readonly", "docx:document:readonly", "docs:document.comment:create", "docs:document.comment:write_only", }, AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ - {Name: "doc", Desc: "document URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/sheet/slides", Required: true}, - {Name: "type", Desc: "document type: doc, docx, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "sheet", "slides"}}, + {Name: "doc", Desc: "document URL/token, file URL/token, sheet/slides URL, or wiki URL that resolves to doc/docx/file/sheet/slides", Required: true}, + {Name: "type", Desc: "document type: doc, docx, file, sheet, slides (required when --doc is a bare token; auto-detected for URLs)", Enum: []string{"doc", "docx", "file", "sheet", "slides"}}, {Name: "content", Desc: "reply_elements JSON string", Required: true}, {Name: "full-comment", Type: "bool", Desc: "create a full-document comment; also the default when no location is provided"}, {Name: "selection-with-ellipsis", Desc: "target content locator (plain text or 'start...end')"}, @@ -145,7 +174,6 @@ var DriveAddComment = common.Shortcut{ } return nil } - selection := runtime.Str("selection-with-ellipsis") blockID := strings.TrimSpace(runtime.Str("block-id")) if strings.TrimSpace(selection) != "" && blockID != "" { @@ -156,6 +184,9 @@ var DriveAddComment = common.Shortcut{ } mode := resolveCommentMode(runtime.Bool("full-comment"), selection, blockID) + if docRef.Kind == "file" { + return validateFileCommentMode(mode, "") + } if mode == commentModeLocal && docRef.Kind == "doc" { return output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments") } @@ -217,6 +248,33 @@ var DriveAddComment = common.Shortcut{ Body(commentBody). Set("file_token", resolvedToken) } + if resolvedKind == "file" { + commentBody := buildCommentCreateV2Request("file", "", "", replyElements, nil) + desc := "2-step orchestration: verify supported file metadata -> create file comment" + verifyStep := "[1]" + createStep := "[2]" + if isWiki { + desc = "3-step orchestration: resolve wiki -> verify supported file metadata -> create file comment" + verifyStep = "[2]" + createStep = "[3]" + } + return common.NewDryRunAPI(). + Desc(desc). + POST("/open-apis/drive/v1/metas/batch_query"). + Desc(verifyStep+" Read file metadata and verify the title extension is supported"). + Body(map[string]interface{}{ + "request_docs": []map[string]interface{}{ + { + "doc_token": resolvedToken, + "doc_type": "file", + }, + }, + }). + POST("/open-apis/drive/v1/files/:file_token/new_comments"). + Desc(createStep+" Create file full comment"). + Body(commentBody). + Set("file_token", resolvedToken) + } // Doc/docx comment dry-run. createPath := "/open-apis/drive/v1/files/:file_token/new_comments" @@ -317,6 +375,9 @@ var DriveAddComment = common.Shortcut{ if target.FileType == "slides" { return executeSlidesComment(runtime, commentDocRef{Kind: "slides", Token: target.FileToken}) } + if target.FileType == "file" { + return executeFileComment(runtime, target) + } replyElements, err := parseCommentReplyElements(runtime.Str("content")) if err != nil { @@ -421,6 +482,9 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) { if token, ok := extractURLToken(raw, "/sheets/"); ok { return commentDocRef{Kind: "sheet", Token: token}, nil } + if token, ok := extractURLToken(raw, "/file/"); ok { + return commentDocRef{Kind: "file", Token: token}, nil + } if token, ok := extractURLToken(raw, "/slides/"); ok { return commentDocRef{Kind: "slides", Token: token}, nil } @@ -431,7 +495,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) { return commentDocRef{Kind: "doc", Token: token}, nil } if strings.Contains(raw, "://") { - return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/sheet/slides", raw) + return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a doc/docx/file/sheet/slides URL, a token with --type, or a wiki URL that resolves to doc/docx/file/sheet/slides", raw) } if strings.ContainsAny(raw, "/?#") { return commentDocRef{}, output.ErrValidation("unsupported --doc input %q: use a token with --type, or a wiki URL", raw) @@ -440,7 +504,7 @@ func parseCommentDocRef(input, docType string) (commentDocRef, error) { // Bare token: --type is required. docType = strings.TrimSpace(docType) if docType == "" { - return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, sheet, slides)") + return commentDocRef{}, output.ErrValidation("--type is required when --doc is a bare token (allowed values: doc, docx, file, sheet, slides)") } return commentDocRef{Kind: docType, Token: raw}, nil } @@ -451,9 +515,16 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i return resolvedCommentTarget{}, err } - if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "sheet" || docRef.Kind == "slides" { - if mode == commentModeLocal && docRef.Kind != "docx" && docRef.Kind != "sheet" && docRef.Kind != "slides" { - return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments") + if docRef.Kind == "docx" || docRef.Kind == "doc" || docRef.Kind == "file" || docRef.Kind == "sheet" || docRef.Kind == "slides" { + if mode == commentModeLocal { + switch docRef.Kind { + case "doc": + return resolvedCommentTarget{}, output.ErrValidation("local comments only support docx, sheet, and slides; old doc format only supports full comments") + case "file": + if err := validateFileCommentMode(mode, ""); err != nil { + return resolvedCommentTarget{}, err + } + } } return resolvedCommentTarget{ DocID: docRef.Token, @@ -507,11 +578,24 @@ func resolveCommentTarget(ctx context.Context, runtime *common.RuntimeContext, i WikiToken: docRef.Token, }, nil } + if objType == "file" { + if err := validateFileCommentMode(mode, objType); err != nil { + return resolvedCommentTarget{}, err + } + fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken)) + return resolvedCommentTarget{ + DocID: objToken, + FileToken: objToken, + FileType: "file", + ResolvedBy: "wiki", + WikiToken: docRef.Token, + }, nil + } if mode == commentModeLocal && objType != "docx" { return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but local comments only support docx, sheet, and slides; for sheet use --block-id !, for slides use --block-id !", objType) } if mode == commentModeFull && objType != "docx" && objType != "doc" { - return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/sheet/slides", objType) + return resolvedCommentTarget{}, output.ErrValidation("wiki resolved to %q, but comments only support doc/docx/file/sheet/slides", objType) } fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to %s: %s\n", objType, common.MaskToken(objToken)) @@ -718,6 +802,10 @@ func buildCommentCreateV2Request(fileType, blockID, slideBlockType string, reply "sheet_col": sheet.Col, "sheet_row": sheet.Row, } + } else if fileType == "file" { + body["anchor"] = map[string]interface{}{ + "block_id": fileFullCommentAnchorBlockID, + } } else if strings.TrimSpace(blockID) != "" { body["anchor"] = map[string]interface{}{ "block_id": blockID, @@ -809,6 +897,107 @@ func parseSheetCellRef(input string) (*sheetAnchor, error) { return &sheetAnchor{SheetID: sheetID, Col: col, Row: row}, nil } +func fetchCommentTargetFileTitle(runtime *common.RuntimeContext, fileToken string) (string, error) { + data, err := runtime.CallAPI( + "POST", + "/open-apis/drive/v1/metas/batch_query", + nil, + map[string]interface{}{ + "request_docs": []map[string]interface{}{ + { + "doc_token": fileToken, + "doc_type": "file", + }, + }, + }, + ) + if err != nil { + return "", err + } + + metas := common.GetSlice(data, "metas") + if len(metas) == 0 { + return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned no metadata for file %s", common.MaskToken(fileToken)) + } + meta, ok := metas[0].(map[string]interface{}) + if !ok { + return "", output.Errorf(output.ExitAPI, "api_error", "drive metas.batch_query returned unexpected metadata format for file %s", common.MaskToken(fileToken)) + } + return common.GetString(meta, "title"), nil +} + +func ensureSupportedFileCommentTarget(runtime *common.RuntimeContext, fileToken string) (string, string, error) { + title, err := fetchCommentTargetFileTitle(runtime, fileToken) + if err != nil { + return "", "", err + } + extension := fileCommentExtension(title) + if isSupportedFileCommentExtension(extension) { + return title, extension, nil + } + if strings.TrimSpace(title) == "" { + return "", "", output.ErrWithHint( + output.ExitValidation, + "unsupported_file_comment_type", + "drive +add-comment does not support comments for this Drive file type yet; the file metadata did not return a title", + "file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(), + ) + } + extensionLabel := extension + if extensionLabel == "" { + extensionLabel = "no extension" + } + return "", "", output.ErrWithHint( + output.ExitValidation, + "unsupported_file_comment_type", + fmt.Sprintf("drive +add-comment does not support comments for this Drive file type yet; got %q (%s)", title, extensionLabel), + "file comments currently support full comments only for these extensions: "+supportedFileCommentExtensionsText(), + ) +} + +func fileCommentExtension(title string) string { + title = strings.TrimSpace(title) + idx := strings.LastIndex(title, ".") + if idx == 0 { + extension := strings.ToLower(title) + if isSupportedFileCommentExtension(extension) { + return extension + } + return "" + } + if idx < 0 || idx == len(title)-1 { + return "" + } + return strings.ToLower(title[idx:]) +} + +func isSupportedFileCommentExtension(extension string) bool { + _, ok := supportedFileCommentExtensionSet[strings.TrimSpace(extension)] + return ok +} + +func supportedFileCommentExtensionsText() string { + return strings.Join(supportedFileCommentExtensions, ", ") +} + +func newSupportedFileCommentExtensionSet(extensions []string) map[string]struct{} { + set := make(map[string]struct{}, len(extensions)) + for _, extension := range extensions { + set[extension] = struct{}{} + } + return set +} + +func validateFileCommentMode(mode commentMode, resolvedObjType string) error { + if mode != commentModeLocal { + return nil + } + if resolvedObjType != "" { + return output.ErrValidation("wiki resolved to %q, but file comments only support full comments; omit --block-id and --selection-with-ellipsis", resolvedObjType) + } + return output.ErrValidation("file comments only support full comments; omit --block-id and --selection-with-ellipsis") +} + func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) error { replyElements, err := parseCommentReplyElements(runtime.Str("content")) if err != nil { @@ -849,6 +1038,48 @@ func executeSheetComment(runtime *common.RuntimeContext, docRef commentDocRef) e return nil } +func executeFileComment(runtime *common.RuntimeContext, target resolvedCommentTarget) error { + replyElements, err := parseCommentReplyElements(runtime.Str("content")) + if err != nil { + return err + } + + title, extension, err := ensureSupportedFileCommentTarget(runtime, target.FileToken) + if err != nil { + return err + } + + requestPath := fmt.Sprintf("/open-apis/drive/v1/files/%s/new_comments", validate.EncodePathSegment(target.FileToken)) + requestBody := buildCommentCreateV2Request("file", "", "", replyElements, nil) + + fmt.Fprintf(runtime.IO().ErrOut, "Creating file comment in %s (%s)\n", common.MaskToken(target.FileToken), extension) + + data, err := runtime.CallAPI("POST", requestPath, nil, requestBody) + if err != nil { + return err + } + + out := map[string]interface{}{ + "comment_id": data["comment_id"], + "doc_id": target.DocID, + "file_token": target.FileToken, + "file_type": "file", + "file_name": title, + "file_extension": extension, + "resolved_by": target.ResolvedBy, + "comment_mode": string(commentModeFull), + } + if createdAt := firstPresentValue(data, "created_at", "create_time"); createdAt != nil { + out["created_at"] = createdAt + } + if target.WikiToken != "" { + out["wiki_token"] = target.WikiToken + } + + runtime.Out(out, nil) + return nil +} + func executeSlidesComment(runtime *common.RuntimeContext, docRef commentDocRef) error { replyElements, err := parseCommentReplyElements(runtime.Str("content")) if err != nil { diff --git a/shortcuts/drive/drive_add_comment_test.go b/shortcuts/drive/drive_add_comment_test.go index ed4278a22..7875893eb 100644 --- a/shortcuts/drive/drive_add_comment_test.go +++ b/shortcuts/drive/drive_add_comment_test.go @@ -105,6 +105,13 @@ func TestParseCommentDocRef(t *testing.T) { wantKind: "doc", wantToken: "docToken", }, + { + name: "raw token with type file", + input: "fileToken", + docType: "file", + wantKind: "file", + wantToken: "fileToken", + }, { name: "raw token without type", input: "xxxxxx", @@ -122,6 +129,12 @@ func TestParseCommentDocRef(t *testing.T) { wantKind: "slides", wantToken: "pres_123", }, + { + name: "file url", + input: "https://example.larksuite.com/file/boxcn123?from=share", + wantKind: "file", + wantToken: "boxcn123", + }, { name: "unsupported url", input: "https://example.com/not-a-doc", @@ -545,6 +558,29 @@ func TestBuildCommentCreateV2RequestFull(t *testing.T) { } } +func TestBuildCommentCreateV2RequestFile(t *testing.T) { + t.Parallel() + + replyElements := []map[string]interface{}{ + { + "type": "text", + "text": "README comment", + }, + } + got := buildCommentCreateV2Request("file", "", "", replyElements, nil) + + if got["file_type"] != "file" { + t.Fatalf("expected file_type file, got %#v", got["file_type"]) + } + anchor, ok := got["anchor"].(map[string]interface{}) + if !ok { + t.Fatalf("expected anchor map, got %#v", got["anchor"]) + } + if blockID, ok := anchor["block_id"].(string); !ok || blockID != fileFullCommentAnchorBlockID { + t.Fatalf("expected file anchor.block_id %q, got %#v", fileFullCommentAnchorBlockID, anchor["block_id"]) + } +} + func TestBuildCommentCreateV2RequestLocal(t *testing.T) { t.Parallel() @@ -906,6 +942,34 @@ func TestSlidesCommentValidateCompoundBlockID(t *testing.T) { } } +func TestFileCommentValidateRejectsBlockID(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/file/fileToken", + "--content", `[{"type":"text","text":"test"}]`, + "--block-id", "blk_123", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") { + t.Fatalf("expected file local-comment rejection, got: %v", err) + } +} + +func TestFileCommentValidateRejectsSelectionWithEllipsis(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/file/fileToken", + "--content", `[{"type":"text","text":"test"}]`, + "--selection-with-ellipsis", "something", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "file comments only support full comments") { + t.Fatalf("expected file local-comment rejection, got: %v", err) + } +} + // ── Slides comment execute tests ──────────────────────────────────────────── func TestSlidesCommentExecuteSuccess(t *testing.T) { @@ -1116,6 +1180,146 @@ func TestSheetCommentViaWikiMissingBlockID(t *testing.T) { } } +func TestFileCommentExecuteSuccess(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "metas": []interface{}{ + map[string]interface{}{"title": "README.txt"}, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/drive/v1/files/fileToken/new_comments", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{"comment_id": "fileComment123", "created_at": 1700000000}, + }, + }) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/file/fileToken", + "--content", `[{"type":"text","text":"请补充 README 示例"}]`, + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "fileComment123") { + t.Fatalf("stdout missing comment_id: %s", stdout.String()) + } + out := decodeJSONMap(t, stdout.String()) + data := mustMapValue(t, out["data"], "data") + if got := mustStringField(t, data, "file_type", "data.file_type"); got != "file" { + t.Fatalf("stdout file_type = %q, want file\nstdout:\n%s", got, stdout.String()) + } + if got := mustStringField(t, data, "file_name", "data.file_name"); got != "README.txt" { + t.Fatalf("stdout file_name = %q, want README.txt\nstdout:\n%s", got, stdout.String()) + } + if got := mustStringField(t, data, "file_extension", "data.file_extension"); got != ".txt" { + t.Fatalf("stdout file_extension = %q, want .txt\nstdout:\n%s", got, stdout.String()) + } +} + +func TestFileCommentExecuteRejectsUnsupportedFileType(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "metas": []interface{}{ + map[string]interface{}{"title": "notes.pdf"}, + }, + }, + }, + }) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/file/fileToken", + "--content", `[{"type":"text","text":"test"}]`, + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "does not support comments for this Drive file type yet") { + t.Fatalf("expected unsupported file comment type error, got: %v", err) + } + if !strings.Contains(err.Error(), "notes.pdf") { + t.Fatalf("expected error to mention unsupported title, got: %v", err) + } +} + +func TestFileCommentExecuteRejectsUnexpectedMetadataFormat(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/drive/v1/metas/batch_query", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "metas": []interface{}{"unexpected"}, + }, + }, + }) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/file/fileToken", + "--content", `[{"type":"text","text":"test"}]`, + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "unexpected metadata format") { + t.Fatalf("expected unexpected metadata format error, got: %v", err) + } +} + +func TestFileCommentSupportedExtensions(t *testing.T) { + t.Parallel() + + supported := []string{ + "README.md", + "notes.TXT", + "data.json", + "table.csv", + "main.go", + "app.js", + "script.py", + "slides.pptx", + "image.png", + "photo.jpg", + "photo.jpeg", + ".md", + "archive.zip", + "audio.mp3", + "video.mp4", + } + for _, title := range supported { + extension := fileCommentExtension(title) + if !isSupportedFileCommentExtension(extension) { + t.Fatalf("%s extension %q should be supported", title, extension) + } + } + + unsupported := []string{ + "report.pdf", + "word.docx", + "sheet.xlsx", + "unknown.bin", + "no-extension", + ".gitignore", + } + for _, title := range unsupported { + extension := fileCommentExtension(title) + if isSupportedFileCommentExtension(extension) { + t.Fatalf("%s extension %q should not be supported", title, extension) + } + } + if extension := fileCommentExtension(".gitignore"); extension != "" { + t.Fatalf("dotfile extension = %q, want empty", extension) + } +} + // ── DryRun coverage ───────────────────────────────────────────────────────── func TestDryRunSheetDirectURL(t *testing.T) { @@ -1346,6 +1550,43 @@ func TestDryRunDocxFullComment(t *testing.T) { } } +func TestDryRunFileDirectURL(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveAddComment, []string{ + "+add-comment", + "--doc", "https://example.larksuite.com/file/fileToken", + "--content", `[{"type":"text","text":"test"}]`, + "--dry-run", "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "verify supported file metadata") { + t.Fatalf("dry-run output missing supported file metadata verification step: %s", stdout.String()) + } + out := decodeJSONMap(t, stdout.String()) + api := mustSliceValue(t, out["api"], "api") + if len(api) != 2 { + t.Fatalf("expected 2 dry-run api calls, got %d\nstdout:\n%s", len(api), stdout.String()) + } + verifyCall := mustMapValue(t, api[0], "api[0]") + createCall := mustMapValue(t, api[1], "api[1]") + verifyBody := mustMapValue(t, verifyCall["body"], "api[0].body") + createBody := mustMapValue(t, createCall["body"], "api[1].body") + requestDocs := mustSliceValue(t, verifyBody["request_docs"], "api[0].body.request_docs") + requestDoc := mustMapValue(t, requestDocs[0], "api[0].body.request_docs[0]") + if got := mustStringField(t, requestDoc, "doc_type", "api[0].body.request_docs[0].doc_type"); got != "file" { + t.Fatalf("metadata query doc_type = %q, want file\nstdout:\n%s", got, stdout.String()) + } + if got := mustStringField(t, createBody, "file_type", "api[1].body.file_type"); got != "file" { + t.Fatalf("comment create file_type = %q, want file\nstdout:\n%s", got, stdout.String()) + } + anchor := mustMapValue(t, createBody["anchor"], "api[1].body.anchor") + if got := mustStringField(t, anchor, "block_id", "api[1].body.anchor.block_id"); got != fileFullCommentAnchorBlockID { + t.Fatalf("comment create anchor.block_id = %q, want %q\nstdout:\n%s", got, fileFullCommentAnchorBlockID, stdout.String()) + } +} + // ── resolveCommentTarget coverage ─────────────────────────────────────────── func TestResolveWikiToDocxFullComment(t *testing.T) { @@ -1397,7 +1638,7 @@ func TestResolveWikiToUnsupportedType(t *testing.T) { "--content", `[{"type":"text","text":"test"}]`, "--as", "user", }, f, stdout) - if err == nil || !strings.Contains(err.Error(), "only support doc/docx/sheet/slides") { + if err == nil || !strings.Contains(err.Error(), "only support doc/docx/file/sheet/slides") { t.Fatalf("expected unsupported type error, got: %v", err) } } diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index e98f07c5c..6e62d1227 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -130,8 +130,8 @@ Drive Folder (云空间文件夹) | 操作 | 需要的 Token | 说明 | |------|-------------|------| | 读取文档内容 | `file_token` / 通过 `docs +fetch --api-version v2` 自动处理 | `docs +fetch --api-version v2` 支持直接传入 URL | -| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `!`,`slides` 使用 `!`,且都支持最终解析到对应类型的 wiki URL | -| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL | +| 添加局部评论(划词评论) | `file_token` | 传 `--block-id` 时,`drive +add-comment` 会创建局部评论;`docx` 支持文本定位或 block_id,`sheet` 使用 `!`,`slides` 使用 `!`,且都支持最终解析到对应类型的 wiki URL;Drive file 不支持局部评论 | +| 添加全文评论 | `file_token` | 不传 `--block-id` 时,`drive +add-comment` 默认创建全文评论;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL | | 下载文件 | `file_token` | 从文件 URL 中直接提取 | | 上传文件 | `folder_token` / `wiki_node_token` | 目标位置的 token | | 列出文档评论 | `file_token` | 同添加评论 | @@ -139,15 +139,16 @@ Drive Folder (云空间文件夹) ### 评论能力边界(关键!) - `drive +add-comment` 支持两种模式。 -- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL,以及最终解析为 `doc`/`docx` 的 wiki URL。 -- 局部评论:传 `--block-id` 时启用;不同文档类型的支持范围与参数格式见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)。 +- 全文评论:未传 `--block-id` 时默认启用,也可显式传 `--full-comment`;支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终解析为 `doc`/`docx`/`file` 的 wiki URL。 +- 局部评论:传 `--block-id` 时启用;`docx` 支持文本定位或 block id,`sheet` 支持 `!`,`slides` 支持 `!`,wiki URL 解析到这些类型时也支持对应局部评论。Drive file 本次只支持全文评论,不支持局部评论。 +- Drive file 评论仅支持白名单扩展名:`.md`、`.txt`、`.json`、`.csv`、`.go`、`.js`、`.py`、`.pptx`、`.png`、`.jpg`、`.jpeg`、`.zip`、`.mp3`、`.mp4`。`.pdf`、`.docx`、`.xlsx` 等未在白名单内的普通文件暂不支持,CLI 会直接报错提示当前还不支持这种类型的评论。 - Review / 审阅 / 校对 / 逐条指出问题场景优先使用局部评论,不要把多个可定位问题汇总成一条全文评论;具体参数和定位方式见 [`drive +add-comment` 行为说明](references/lark-drive-add-comment.md#行为说明)。 - `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`。 - 评论写入内容(添加评论、回复评论、编辑回复)里的文本不能直接出现 `<`、`>`;提交前必须先转义:`<` -> `<`,`>` -> `>`。 - 使用 `drive +add-comment` 时,shortcut 会对 `type=text` 的文本元素自动做上述转义兜底;如果直接调用 `drive file.comments create_v2`、`drive file.comment.replys create`、`drive file.comment.replys update`,则需要在请求里自行传入已转义的内容。 -- 如果 wiki 解析后不是 `doc`/`docx`/`sheet`/`slides`,不要用 `+add-comment`。 +- 如果 wiki 解析后不是 `doc`/`docx`/`file`/`sheet`/`slides`,不要用 `+add-comment`。 - 如果需要更底层地直接调用评论 V2 协议,再走原生 API:先执行 `lark-cli schema drive.file.comments.create_v2`,再执行 `lark-cli drive file.comments create_v2 ...`。全文评论省略 `anchor`,局部评论传 `anchor.block_id`。 ### 评论查询与统计口径(关键!) @@ -265,7 +266,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+pull`](references/lark-drive-pull.md) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside cwd. | | `+sync` | Two-way local ↔ Drive sync. Reuses `+status` diff buckets, pulls `new_remote`, pushes `new_local`, and resolves `modified` via `--on-conflict=remote-wins|local-wins|keep-both|ask`. `--quick` enables best-effort modified-time diffing (timestamp mismatches can still trigger real pull/push actions), `--on-duplicate-remote` supports `fail|newest|oldest`, and the command is intentionally non-destructive (no delete on either side). | | [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder | -| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides | +| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/file/sheet/slides, also supports wiki URL resolving to doc/docx/file/sheet/slides; file targets support selected extensions and full comments only | | [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming | | [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token | | [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) | diff --git a/skills/lark-drive/references/lark-drive-add-comment.md b/skills/lark-drive/references/lark-drive-add-comment.md index 41ac99975..35c181537 100644 --- a/skills/lark-drive/references/lark-drive-add-comment.md +++ b/skills/lark-drive/references/lark-drive-add-comment.md @@ -3,7 +3,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -给文档、电子表格或飞书幻灯片添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments`(`create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL(仅全文评论)、sheet URL、slides URL,也支持传最终可解析为 doc/docx/sheet/slides 的 wiki URL。 +给文档、受支持的 Drive 普通文件、电子表格或飞书幻灯片添加评论。底层统一走 `/open-apis/drive/v1/files/:file_token/new_comments`(`create_v2`)接口;未指定位置时省略 `anchor` 创建全文评论,指定 `--block-id` 时传入 `anchor.block_id` 创建局部评论。支持直接传 docx URL/token、旧版 doc URL(仅全文评论)、Drive file URL/token(**仅支持白名单扩展名,且只支持全文评论**)、sheet URL、slides URL,也支持传最终可解析为 doc/docx/file/sheet/slides 的 wiki URL。 ## 命令 @@ -24,13 +24,24 @@ lark-cli drive +add-comment \ --doc "https://example.larksuite.com/wiki/" \ --content '[{"type":"text","text":"这里需要一段全文评论"}]' +# 给受支持的 Drive 普通文件添加全文评论 +# 注意:CLI 会先查询 drive metas,只有白名单扩展名才允许评论 +lark-cli drive +add-comment \ + --doc "https://example.larksuite.com/file/" \ + --content '[{"type":"text","text":"请补充文件说明"}]' + +# 裸 token 也支持,但必须显式声明 --type file +lark-cli drive +add-comment \ + --doc "" --type file \ + --content '[{"type":"text","text":"请补充目录说明"}]' + # 给 docx 文档的指定 block 添加局部评论(block_id 可通过 docs +fetch --api-version v2 --detail with-ids 获取) lark-cli drive +add-comment \ --doc "https://example.larksuite.com/docx/" \ --block-id "" \ --content '[{"type":"text","text":"请补充流程说明"}]' -# wiki 链接也支持局部评论,但解析结果必须是 docx +# wiki 链接也支持局部评论;解析结果可以是 docx/sheet/slides,block-id 格式按目标类型传 lark-cli drive +add-comment \ --doc "https://example.larksuite.com/wiki/" \ --block-id "" \ @@ -128,8 +139,8 @@ lark-cli drive +add-comment \ | 参数 | 必填 | 说明 | |------|------|------| -| `--doc` | 是 | 文档 URL / token、sheet / slides URL,或可解析到 `doc`/`docx`/`sheet`/`slides` 的 wiki URL | -| `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`sheet`、`slides`。URL 输入时自动识别,无需传 | +| `--doc` | 是 | 文档 URL / token、file / sheet / slides URL,或可解析到 `doc`/`docx`/`file`/`sheet`/`slides` 的 wiki URL | +| `--type` | 裸 token 时必填 | 文档类型:`doc`、`docx`、`file`、`sheet`、`slides`。URL 输入时自动识别,无需传 | | `--content` | 是 | `reply_elements` JSON 数组字符串。示例:`'[{"type":"text","text":"文本"},{"type":"mention_user","text":"ou_xxx"},{"type":"link","text":"https://example.com"}]'` | | `--full-comment` | 否 | 显式指定创建全文评论;未传 `--block-id` 时也会默认走全文评论(不适用于 sheet) | | `--block-id` | 局部评论时必填 | 目标块 ID,可通过 `docs +fetch --api-version v2 --detail with-ids` 获取。**Sheet 评论**:格式为 `!`(如 `a281f9!D6`) | @@ -138,7 +149,10 @@ lark-cli drive +add-comment \ - **局部评论需要先获取 block ID**:先调用 `docs +fetch --api-version v2 --doc --detail with-ids` 获取带有 block ID 的文档内容,然后使用 `--block-id` 指定目标块。 - **Review 场景优先局部评论**:审阅、校对、逐条指出问题时,必须先尝试定位到具体 block / 单元格 / slide 元素,并逐问题创建局部评论;不要把所有问题合并成一条全文评论。 -- 未传 `--block-id` 时,shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL,以及最终可解析为 `doc`/`docx` 的 wiki URL。 +- 未传 `--block-id` 时,shortcut 默认创建**全文评论**;也可以显式传 `--full-comment`。全文评论支持 `docx`、旧版 `doc` URL、白名单扩展名的 Drive file,以及最终可解析为 `doc`/`docx`/`file` 的 wiki URL。 +- **Drive file 评论**:仅支持白名单扩展名的普通文件。当前支持:`.md`、`.txt`、`.json`、`.csv`、`.go`、`.js`、`.py`、`.pptx`、`.png`、`.jpg`、`.jpeg`、`.zip`、`.mp3`、`.mp4`。 +- **Drive file 暂不支持**:`.pdf`、`.docx`、`.xlsx` 等未在白名单内的普通文件会被 CLI 拒绝,并提示“当前还不支持这种类型的评论”。这些类型虽然可能接受 OpenAPI 请求,但在页面评论展示上存在问题。 +- **Drive file 只支持全文评论**:file 目标不支持局部评论,不允许传 `--block-id` 或 `--selection-with-ellipsis`。由于当前 OpenAPI 要求 file 评论传入非空 `anchor.block_id`,CLI 会固定传占位值 `test`,UI 上仍表现为文件全文评论。 - 传 `--block-id` 时,shortcut 创建**局部评论(划词评论)**;该模式支持 `docx`、`sheet`、`slides`,以及最终可解析为这些类型的 wiki URL。 - **Sheet 评论**:当 `--doc` 为 sheet URL 或 wiki 解析为 sheet 时,使用 `--block-id "!"` 指定单元格(如 `a281f9!D6`);sheet 没有全文评论,`--full-comment` 不可用。 - **Slide 评论**:当 `--doc` 为 slides URL、`--type slides`,或 wiki 解析为 slides 时,必须传 `--block-id "!"`。CLI 会将其拆分映射到 `anchor.block_id` / `anchor.slide_block_type`。此时 `--full-comment` 和 `--selection-with-ellipsis` 不可用。 diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index 337323cbc..9cfc8f26c 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -2,8 +2,8 @@ ## Metrics - Denominator: 29 leaf commands -- Covered: 8 -- Coverage: 27.6% +- Covered: 9 +- Coverage: 31.0% ## 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`. @@ -11,18 +11,20 @@ - TestDrive_UploadWorkflow: proves `drive +upload` against the real backend in both create and overwrite modes. First uploads a fresh file into a temporary Drive folder, then re-uploads new bytes with `--file-token` against the returned token, asserts the overwrite keeps the token stable, and finally downloads the file to confirm the remote content changed. - 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: 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`. - 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. - Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered. -- Blocked area: live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup. +- Blocked area: live export, permission, subscription, reply, and file comment API flows still need deterministic remote fixtures and filesystem setup. - Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` and `TestDriveUploadDryRun_WithFileToken` cover the wiki-target and overwrite request shapes for `drive +upload`; live upload/status/duplicate workflows also use real `+upload` against the backend. ## Command Table | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | -| ✕ | drive +add-comment | shortcut | | none | no comment workflow yet | +| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id` | dry-run coverage in place; opt-in live workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` | | ✓ | 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_dryrun_test.go b/tests/cli_e2e/drive/drive_add_comment_dryrun_test.go new file mode 100644 index 000000000..1a65239fc --- /dev/null +++ b/tests/cli_e2e/drive/drive_add_comment_dryrun_test.go @@ -0,0 +1,53 @@ +// 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 TestDriveAddCommentDryRun_File(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", "+add-comment", + "--doc", "https://example.larksuite.com/file/fileDryRunComment", + "--content", `[{"type":"text","text":"please update README"}]`, + "--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/metas/batch_query" { + t.Fatalf("api.0.url=%q, want metas/batch_query\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.0.body.request_docs.0.doc_type").String(); got != "file" { + t.Fatalf("api.0.body.request_docs.0.doc_type=%q, want file\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/fileDryRunComment/new_comments" { + t.Fatalf("api.1.url=%q, want new_comments\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "api.1.body.file_type").String(); got != "file" { + t.Fatalf("api.1.body.file_type=%q, want file\nstdout:\n%s", got, out) + } + if !gjson.Get(out, "api.1.body.anchor.block_id").Exists() { + t.Fatalf("api.1.body.anchor.block_id should exist for file comment\nstdout:\n%s", out) + } + if got := gjson.Get(out, "api.1.body.anchor.block_id").String(); got != "test" { + t.Fatalf("api.1.body.anchor.block_id=%q, want test\nstdout:\n%s", got, out) + } +} diff --git a/tests/cli_e2e/drive/drive_add_comment_workflow_test.go b/tests/cli_e2e/drive/drive_add_comment_workflow_test.go new file mode 100644 index 000000000..1ea83326f --- /dev/null +++ b/tests/cli_e2e/drive/drive_add_comment_workflow_test.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "os" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDriveAddCommentMarkdownFileWorkflow(t *testing.T) { + if os.Getenv("LARK_DRIVE_MD_COMMENT_E2E") == "" { + t.Skip("set LARK_DRIVE_MD_COMMENT_E2E=1 to run the supported file comment workflow") + } + + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + fileName := "lark-cli-e2e-drive-comment-" + suffix + ".md" + + createResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+create", + "--name", fileName, + "--content", "# Comment target\n\nbody\n", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + createResult.AssertExitCode(t, 0) + createResult.AssertStdoutStatus(t, true) + + fileToken := gjson.Get(createResult.Stdout, "data.file_token").String() + require.NotEmpty(t, fileToken, "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", fileToken, + "--type", "file", + "--yes", + }, + DefaultAs: "bot", + }) + clie2e.ReportCleanupFailure(parentT, "delete file comment target "+fileToken, deleteResult, deleteErr) + }) + + commentResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "drive", "+add-comment", + "--doc", fileToken, + "--type", "file", + "--content", `[{"type":"text","text":"please update README"}]`, + }, + DefaultAs: "bot", + }, 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) + if got := gjson.Get(commentResult.Stdout, "data.file_type").String(); got != "file" { + t.Fatalf("data.file_type=%q, want file\nstdout:\n%s", got, commentResult.Stdout) + } + if got := gjson.Get(commentResult.Stdout, "data.file_name").String(); got != fileName { + t.Fatalf("data.file_name=%q, want %q\nstdout:\n%s", got, fileName, commentResult.Stdout) + } + 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) + } +}