diff --git a/README.md b/README.md index 7a23eee44..cdffa3551 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,14 @@ [中文版](./README.zh.md) | [English](./README.md) -The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 22 AI Agent [Skills](./skills/). +The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 23 AI Agent [Skills](./skills/). [Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing) ## Why lark-cli? - **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup -- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 AI Agent [Skills](./skills/) +- **Wide Coverage** — 15 business domains, 200+ curated commands, 23 AI Agent [Skills](./skills/) - **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates - **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install` - **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps @@ -38,7 +38,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t | 🎥 Meetings | Search meeting records, query meeting minutes & recordings | | 🕐 Attendance | Query personal attendance check-in records | | ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances | -| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments and indicators. | +| 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. | ## Installation & Quick Start @@ -132,14 +132,14 @@ lark-cli auth status ## Agent Skills | Skill | Description | -| ------------------------------- |----------------------------------------------------------------------------------------------------------------| +|---------------------------------|----------------------------------------------------------------------------------------------------------------| | `lark-shared` | App config, auth login, identity switching, scope management, security rules (auto-loaded by all other skills) | | `lark-calendar` | Calendar events, agenda view, free/busy queries, time suggestions | | `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions | | `lark-doc` | Create, read, update, search documents (Markdown-based) | | `lark-drive` | Upload, download files, manage permissions & comments | | `lark-sheets` | Create, read, write, append, find, export spreadsheets | -| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides | +| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides | | `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics | | `lark-task` | Tasks, task lists, subtasks, reminders, member assignment | | `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail | @@ -155,6 +155,7 @@ lark-cli auth status | `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances | | `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report | | `lark-workflow-standup-report` | Workflow: agenda & todo summary | +| `lark-okr` | Query, create, update OKRs; manage objective & key results, alignments and indicators. | ## Authentication diff --git a/README.zh.md b/README.zh.md index 5f68b880d..49615841d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,14 +6,14 @@ [中文版](./README.zh.md) | [English](./README.md) -飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。 +飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。 [安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献) ## 为什么选 lark-cli? - **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具,Agent 无需额外适配即可操作飞书 -- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/) +- **覆盖面广** — 15 大业务域、200+ 精选命令、23 个 AI Agent [Skills](./skills/) - **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率 - **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用 - **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步 @@ -22,23 +22,23 @@ ## 功能 -| 类别 | 能力 | -| ------------- |--------------------------------------------| -| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 | -| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 | -| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 | -| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 | +| 类别 | 能力 | +| ------------- |------------------------------------------| +| 📅 日历 | 查看日程、创建日程、邀请参会人、查询忙闲状态、时间建议 | +| 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 | +| 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 | +| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 | | 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 | -| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 | -| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 | -| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 | -| 📚 知识库 | 创建和管理知识空间、节点和文档 | -| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 | -| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 | -| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 | -| 🕐 考勤打卡 | 查询个人考勤打卡记录 | -| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 | -| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 | +| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 | +| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 | +| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 | +| 📚 知识库 | 创建和管理知识空间、节点和文档 | +| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 | +| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 | +| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 | +| 🕐 考勤打卡 | 查询个人考勤打卡记录 | +| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 | +| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 | ## 安装与快速开始 @@ -133,14 +133,14 @@ lark-cli auth status ## Agent Skills | Skill | 说明 | -| --------------------------------- |-------------------------------------------| +|---------------------------------|-------------------------------------------| | `lark-shared` | 应用配置、认证登录、身份切换、权限管理、安全规则(所有其他 skill 自动加载) | | `lark-calendar` | 日历日程、议程查看、忙闲查询、时间建议 | | `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 | | `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) | | `lark-drive` | 上传、下载文件,管理权限与评论 | | `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 | -| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 | +| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 | | `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 | | `lark-task` | 任务、任务清单、子任务、提醒、成员分配 | | `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 | @@ -156,6 +156,7 @@ lark-cli auth status | `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 | | `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 | | `lark-workflow-standup-report` | 工作流:日程待办摘要 | +| `lark-okr` | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 | ## 认证 diff --git a/internal/registry/service_descriptions.json b/internal/registry/service_descriptions.json index 14aac95ff..1df8581b0 100644 --- a/internal/registry/service_descriptions.json +++ b/internal/registry/service_descriptions.json @@ -66,6 +66,6 @@ }, "okr": { "en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators" }, - "zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标" } + "zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标、进展记录" } } } diff --git a/shortcuts/okr/okr_cli_resp.go b/shortcuts/okr/okr_cli_resp.go index 0dba593ba..dc45a58de 100644 --- a/shortcuts/okr/okr_cli_resp.go +++ b/shortcuts/okr/okr_cli_resp.go @@ -99,3 +99,56 @@ type RespOwner struct { OwnerType string `json:"owner_type"` UserID *string `json:"user_id,omitempty"` } + +// ProgressStatus 进展状态 +type ProgressStatus int32 + +const ( + ProgressStatusNormal ProgressStatus = 0 // 正常 + ProgressStatusOverdue ProgressStatus = 1 // 逾期 + ProgressStatusDone ProgressStatus = 2 // 已完成 +) + +// ParseProgressStatus parses a progress status string into ProgressStatus. +// Accepts "normal", "overdue", "done" or their numeric values "0", "1", "2". +func ParseProgressStatus(s string) (ProgressStatus, bool) { + switch s { + case "normal", "0": + return ProgressStatusNormal, true + case "overdue", "1": + return ProgressStatusOverdue, true + case "done", "2": + return ProgressStatusDone, true + default: + return 0, false + } +} + +// String returns a human-readable name for ProgressStatus. +func (s ProgressStatus) String() string { + switch s { + case ProgressStatusNormal: + return "normal" + case ProgressStatusOverdue: + return "overdue" + case ProgressStatusDone: + return "done" + default: + return "" + } +} + +// RespProgressRate 进度率(面向用户的响应格式,Status 为可读字符串) +type RespProgressRate struct { + Percent *float64 `json:"percent,omitempty"` + Status *string `json:"status,omitempty"` +} + +// RespProgress 进展记录 +type RespProgress struct { + ID string `json:"progress_id"` + ModifyTime string `json:"modify_time"` + CreateTime *string `json:"create_time,omitempty"` + Content *string `json:"content,omitempty"` + ProgressRate *RespProgressRate `json:"progress_rate,omitempty"` +} diff --git a/shortcuts/okr/okr_image_upload.go b/shortcuts/okr/okr_image_upload.go new file mode 100644 index 000000000..d81e8d4b6 --- /dev/null +++ b/shortcuts/okr/okr_image_upload.go @@ -0,0 +1,146 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strconv" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// allowedImageExts lists the file extensions supported by the OKR image upload API. +var allowedImageExts = map[string]bool{ + ".jpg": true, + ".jpeg": true, + ".png": true, + ".gif": true, + ".bmp": true, +} + +// OKRUploadImage uploads an image for use in OKR progress rich text. +var OKRUploadImage = common.Shortcut{ + Service: "okr", + Command: "+upload-image", + Description: "Upload an image for use in OKR progress rich text", + Risk: "write", + Scopes: []string{"okr:okr.progress.file:upload"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file", Desc: "local image path (supports JPG, JPEG, PNG, GIF, BMP)", Required: true}, + {Name: "target-id", Desc: "target ID (objective or key result ID) for the progress", Required: true}, + {Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + if filePath == "" { + return common.FlagErrorf("--file is required") + } + ext := strings.ToLower(filepath.Ext(filePath)) + if !allowedImageExts[ext] { + return common.FlagErrorf("--file must be an image (supported: JPG, JPEG, PNG, GIF, BMP), got %q", ext) + } + + targetID := runtime.Str("target-id") + if targetID == "" { + return common.FlagErrorf("--target-id is required") + } + if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 { + return common.FlagErrorf("--target-id must be a positive int64") + } + + targetType := runtime.Str("target-type") + if _, ok := targetTypeAllowed[targetType]; !ok { + return common.FlagErrorf("--target-type must be one of: objective | key_result") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + filePath := runtime.Str("file") + targetID := runtime.Str("target-id") + targetType := runtime.Str("target-type") + targetTypeVal := targetTypeAllowed[targetType] + + return common.NewDryRunAPI(). + POST("/open-apis/okr/v1/images/upload"). + Body(map[string]interface{}{ + "file": "@" + filePath, + "target_id": targetID, + "target_type": targetTypeVal, + }). + Desc(fmt.Sprintf("Upload image for OKR %s %s", targetType, targetID)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + filePath := runtime.Str("file") + targetID := runtime.Str("target-id") + targetType := runtime.Str("target-type") + targetTypeVal := targetTypeAllowed[targetType] + + info, err := runtime.FileIO().Stat(filePath) + if err != nil { + return common.WrapInputStatError(err) + } + + f, err := runtime.FileIO().Open(filePath) + if err != nil { + return common.WrapInputStatError(err) + } + defer f.Close() + + fileName := filepath.Base(filePath) + fmt.Fprintf(runtime.IO().ErrOut, "Uploading: %s (%s)\n", fileName, common.FormatSize(info.Size())) + + fd := larkcore.NewFormdata() + fd.AddField("target_id", targetID) + fd.AddField("target_type", fmt.Sprintf("%d", targetTypeVal)) + fd.AddFile("data", f) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: "POST", + ApiPath: "/open-apis/okr/v1/images/upload", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return err + } + return output.ErrNetwork("upload failed: %v", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(apiResp.RawBody, &result); err != nil { + return output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err) + } + + if larkCode := int(common.GetFloat(result, "code")); larkCode != 0 { + msg, _ := result["msg"].(string) + return output.ErrAPI(larkCode, fmt.Sprintf("upload failed: [%d] %s", larkCode, msg), result["error"]) + } + + data, _ := result["data"].(map[string]interface{}) + fileToken, _ := data["file_token"].(string) + url, _ := data["url"].(string) + + if fileToken == "" { + return output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + } + + runtime.Out(map[string]interface{}{ + "file_token": fileToken, + "url": url, + "file_name": fileName, + "size": info.Size(), + }, nil) + return nil + }, +} diff --git a/shortcuts/okr/okr_image_upload_test.go b/shortcuts/okr/okr_image_upload_test.go new file mode 100644 index 000000000..40d34d0fa --- /dev/null +++ b/shortcuts/okr/okr_image_upload_test.go @@ -0,0 +1,457 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "bytes" + "mime" + "mime/multipart" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +func uploadImageTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + return &core.CliConfig{ + AppID: "test-okr-upload-image", + AppSecret: "secret-okr-upload-image", + Brand: core.BrandFeishu, + } +} + +func runUploadImageShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + parent := &cobra.Command{Use: "okr"} + OKRUploadImage.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// --- Validate tests --- + +func TestUploadImageValidate_MissingFile(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + // --file is a Required flag, so cobra rejects before our Validate runs. + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for missing --file") + } +} + +func TestUploadImageValidate_InvalidExtension(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "document.pdf", + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for invalid --file extension") + } + if !strings.Contains(err.Error(), "--file must be an image") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUploadImageValidate_MissingTargetID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./photo.png", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for missing --target-id") + } +} + +func TestUploadImageValidate_InvalidTargetID_NonNumeric(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./photo.png", + "--target-id", "abc", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for non-numeric --target-id") + } + if !strings.Contains(err.Error(), "--target-id must be a positive int64") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUploadImageValidate_InvalidTargetType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./photo.png", + "--target-id", "123", + "--target-type", "invalid", + }) + if err == nil { + t.Fatal("expected error for invalid --target-type") + } + if !strings.Contains(err.Error(), "--target-type") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestUploadImageValidate_ValidObjective(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + if err := os.WriteFile("photo.png", []byte("png-bytes"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/images/upload", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "file_token": "test_token", + "url": "https://example.com/download", + }, + }, + }) + + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./photo.png", + "--target-id", "6974586812998174252", + "--target-type", "objective", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- DryRun tests --- + +func TestUploadImageDryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./photo.png", + "--target-id", "6974586812998174252", + "--target-type", "objective", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "/open-apis/okr/v1/images/upload") { + t.Fatalf("dry-run output should contain API path, got: %s", output) + } + if !strings.Contains(output, "POST") { + t.Fatalf("dry-run output should contain POST method, got: %s", output) + } + if !strings.Contains(output, "target_id") { + t.Fatalf("dry-run output should contain target_id, got: %s", output) + } +} + +func TestUploadImageDryRun_KeyResult(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./image.jpg", + "--target-id", "123", + "--target-type", "key_result", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "key_result") { + t.Fatalf("dry-run output should mention key_result, got: %s", output) + } +} + +// --- Execute tests --- + +func TestUploadImageExecute_Success(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + if err := os.WriteFile("photo.png", []byte("png-bytes"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/images/upload", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "file_token": "test_token", + "url": "https://example.com/download?file_token=test_token", + }, + }, + }) + + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./photo.png", + "--target-id", "6974586812998174252", + "--target-type", "objective", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeEnvelope(t, stdout) + if data["file_token"] != "test_token" { + t.Fatalf("file_token = %v, want test_token", data["file_token"]) + } + if data["file_name"] != "photo.png" { + t.Fatalf("file_name = %v, want photo.png", data["file_name"]) + } + if data["url"] == "" { + t.Fatal("url should not be empty") + } +} + +func TestUploadImageExecute_KeyResultType(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + if err := os.WriteFile("img.jpeg", []byte("jpeg-bytes"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/images/upload", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "file_token": "boxTestKRToken", + "url": "https://example.com/download", + }, + }, + } + reg.Register(uploadStub) + + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./img.jpeg", + "--target-id", "999", + "--target-type", "key_result", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeEnvelope(t, stdout) + if data["file_token"] != "boxTestKRToken" { + t.Fatalf("file_token = %v, want boxTestKRToken", data["file_token"]) + } + + // Verify multipart body contains correct target_type value + body := decodeUploadImageMultipart(t, uploadStub) + if body.Fields["target_type"] != "3" { + t.Fatalf("target_type = %q, want 3 (key_result)", body.Fields["target_type"]) + } + if body.Fields["target_id"] != "999" { + t.Fatalf("target_id = %q, want 999", body.Fields["target_id"]) + } +} + +func TestUploadImageExecute_ObjectiveType(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + if err := os.WriteFile("img.gif", []byte("gif-bytes"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/images/upload", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "file_token": "boxOToken", + "url": "https://example.com/download", + }, + }, + } + reg.Register(uploadStub) + + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./img.gif", + "--target-id", "456", + "--target-type", "objective", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeUploadImageMultipart(t, uploadStub) + if body.Fields["target_type"] != "2" { + t.Fatalf("target_type = %q, want 2 (objective)", body.Fields["target_type"]) + } +} + +func TestUploadImageExecute_APIError(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + if err := os.WriteFile("photo.png", []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/images/upload", + Status: 400, + Body: map[string]interface{}{ + "code": 1001001, + "msg": "invalid parameters", + }, + }) + + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./photo.png", + "--target-id", "789", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for API failure") + } +} + +func TestUploadImageExecute_FileNotFound(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./missing.png", + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestUploadImageExecute_NoFileTokenInResponse(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, uploadImageTestConfig(t)) + + tmpDir := t.TempDir() + cmdutil.TestChdir(t, tmpDir) + if err := os.WriteFile("photo.png", []byte("x"), 0o644); err != nil { + t.Fatalf("write file: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/images/upload", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{}, + }, + }) + + err := runUploadImageShortcut(t, f, stdout, []string{ + "+upload-image", + "--file", "./photo.png", + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for missing file_token in response") + } + if !strings.Contains(err.Error(), "no file_token returned") { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- Multipart body decoding helpers --- + +type capturedUploadMultipart struct { + Fields map[string]string + Files map[string][]byte +} + +func decodeUploadImageMultipart(t *testing.T, stub *httpmock.Stub) capturedUploadMultipart { + t.Helper() + contentType := stub.CapturedHeaders.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("parse content-type %q: %v", contentType, err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content type = %q, want multipart/form-data", mediaType) + } + reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"]) + body := capturedUploadMultipart{Fields: map[string]string{}, Files: map[string][]byte{}} + for { + part, err := reader.NextPart() + if err != nil { + break + } + var buf bytes.Buffer + tmp := make([]byte, 4096) + for { + n, readErr := part.Read(tmp) + if n > 0 { + buf.Write(tmp[:n]) + } + if readErr != nil { + break + } + } + if part.FileName() != "" { + body.Files[part.FormName()] = buf.Bytes() + continue + } + body.Fields[part.FormName()] = buf.String() + } + return body +} diff --git a/shortcuts/okr/okr_openapi.go b/shortcuts/okr/okr_openapi.go index 0a4f2f8e0..32794030e 100644 --- a/shortcuts/okr/okr_openapi.go +++ b/shortcuts/okr/okr_openapi.go @@ -77,6 +77,16 @@ const ( func (t ParagraphElementType) Ptr() *ParagraphElementType { return &t } +type ParagraphElementTypeV1 string + +const ( + ParagraphElementTypeV1DocsLink ParagraphElementTypeV1 = "docsLink" + ParagraphElementTypeV1Mention ParagraphElementTypeV1 = "person" + ParagraphElementTypeV1TextRun ParagraphElementTypeV1 = "textRun" +) + +func (t ParagraphElementTypeV1) Ptr() *ParagraphElementTypeV1 { return &t } + // ContentBlock 内容块 type ContentBlock struct { Blocks []ContentBlockElement `json:"blocks,omitempty"` @@ -359,3 +369,467 @@ func ptrFloat64(p *float64) float64 { } return *p } + +// ========== ContentBlockV1 (for OKR v1 API ContentBlock) ========== + +// ContentBlockV1 是 OKR v1 API 使用的内容块 +type ContentBlockV1 struct { + Blocks []ContentBlockElementV1 `json:"blocks,omitempty"` +} + +// ContentBlockElementV1 内容块元素 +type ContentBlockElementV1 struct { + Type *BlockElementType `json:"type,omitempty"` + Paragraph *ContentParagraphV1 `json:"paragraph,omitempty"` + Gallery *ContentGalleryV1 `json:"gallery,omitempty"` +} + +// ContentGalleryV1 图库 +type ContentGalleryV1 struct { + ImageList []ContentImageItemV1 `json:"imageList,omitempty"` +} + +// ContentImageItemV1 图片项 +type ContentImageItemV1 struct { + FileToken *string `json:"fileToken,omitempty"` + Src *string `json:"src,omitempty"` + Width *float64 `json:"width,omitempty"` + Height *float64 `json:"height,omitempty"` +} + +// ContentParagraphV1 段落 +type ContentParagraphV1 struct { + Style *ContentParagraphStyleV1 `json:"style,omitempty"` + Elements []ContentParagraphElementV1 `json:"elements,omitempty"` +} + +// ContentParagraphElementV1 段落元素 +type ContentParagraphElementV1 struct { + Type *ParagraphElementTypeV1 `json:"type,omitempty"` + TextRun *ContentTextRunV1 `json:"textRun,omitempty"` + DocsLink *ContentDocsLink `json:"docsLink,omitempty"` + Person *ContentPersonV1 `json:"person,omitempty"` +} + +// ContentParagraphStyleV1 段落样式 +type ContentParagraphStyleV1 struct { + List *ContentListV1 `json:"list,omitempty"` +} + +// ContentListV1 列表 +type ContentListV1 struct { + Type *ListType `json:"type,omitempty"` + IndentLevel *int32 `json:"indentLevel,omitempty"` + Number *int32 `json:"number,omitempty"` +} + +// ContentPersonV1 提及的人 +type ContentPersonV1 struct { + OpenID *string `json:"openId,omitempty"` +} + +// ContentTextRunV1 文本块 +type ContentTextRunV1 struct { + Text *string `json:"text,omitempty"` + Style *ContentTextStyleV1 `json:"style,omitempty"` +} + +// ContentTextStyleV1 文本样式 +type ContentTextStyleV1 struct { + Bold *bool `json:"bold,omitempty"` + StrikeThrough *bool `json:"strikeThrough,omitempty"` + BackColor *ContentColor `json:"backColor,omitempty"` + TextColor *ContentColor `json:"textColor,omitempty"` + Link *ContentLink `json:"link,omitempty"` +} + +// ToV1 将 ContentBlock 转换为 ContentBlockV1 +func (c *ContentBlock) ToV1() *ContentBlockV1 { + if c == nil { + return nil + } + result := &ContentBlockV1{} + for _, block := range c.Blocks { + result.Blocks = append(result.Blocks, block.ToV1()) + } + return result +} + +// ToV1 将 ContentBlockElement 转换为 ContentBlockElementV1 +func (e *ContentBlockElement) ToV1() ContentBlockElementV1 { + return ContentBlockElementV1{ + Type: e.BlockElementType, + Paragraph: e.Paragraph.ToV1(), + Gallery: e.Gallery.ToV1(), + } +} + +// ToV1 将 ContentGallery 转换为 ContentGalleryV1 +func (g *ContentGallery) ToV1() *ContentGalleryV1 { + if g == nil { + return nil + } + imageList := make([]ContentImageItemV1, 0, len(g.Images)) + for _, img := range g.Images { + imageList = append(imageList, img.ToV1()) + } + return &ContentGalleryV1{ + ImageList: imageList, + } +} + +// ToV1 将 ContentImageItem 转换为 ContentImageItemV1 +func (i *ContentImageItem) ToV1() ContentImageItemV1 { + return ContentImageItemV1{ + FileToken: i.FileToken, + Src: i.Src, + Width: i.Width, + Height: i.Height, + } +} + +// ToV1 将 ContentParagraph 转换为 ContentParagraphV1 +func (p *ContentParagraph) ToV1() *ContentParagraphV1 { + if p == nil { + return nil + } + result := &ContentParagraphV1{ + Style: p.Style.ToV1(), + } + for _, elem := range p.Elements { + result.Elements = append(result.Elements, elem.ToV1()) + } + return result +} + +// ToV1 将 ParagraphElementType 转换为 ParagraphElementTypeV1 +func (t ParagraphElementType) ToV1() ParagraphElementTypeV1 { + switch t { + case ParagraphElementTypeDocsLink: + return ParagraphElementTypeV1DocsLink + case ParagraphElementTypeMention: + return ParagraphElementTypeV1Mention // "person" + case ParagraphElementTypeTextRun: + return ParagraphElementTypeV1TextRun + default: + return ParagraphElementTypeV1(t) + } +} + +// ToV2 将 ParagraphElementTypeV1 转换为 ParagraphElementType +func (t ParagraphElementTypeV1) ToV2() ParagraphElementType { + switch t { + case ParagraphElementTypeV1DocsLink: + return ParagraphElementTypeDocsLink + case ParagraphElementTypeV1Mention: // "person" + return ParagraphElementTypeMention + case ParagraphElementTypeV1TextRun: + return ParagraphElementTypeTextRun + default: + return ParagraphElementType(t) + } +} + +// ToV1 将 ContentParagraphElement 转换为 ContentParagraphElementV1 +func (e *ContentParagraphElement) ToV1() ContentParagraphElementV1 { + t := ParagraphElementTypeV1TextRun + if e.ParagraphElementType != nil { + t = e.ParagraphElementType.ToV1() + } + return ContentParagraphElementV1{ + Type: t.Ptr(), + TextRun: e.TextRun.ToV1(), + DocsLink: e.DocsLink, + Person: e.Mention.ToV1(), + } +} + +// ToV1 将 ContentParagraphStyle 转换为 ContentParagraphStyleV1 +func (s *ContentParagraphStyle) ToV1() *ContentParagraphStyleV1 { + if s == nil { + return nil + } + return &ContentParagraphStyleV1{ + List: s.List.ToV1(), + } +} + +// ToV1 将 ContentList 转换为 ContentListV1 +func (l *ContentList) ToV1() *ContentListV1 { + if l == nil { + return nil + } + return &ContentListV1{ + Type: l.ListType, + IndentLevel: l.IndentLevel, + Number: l.Number, + } +} + +// ToV1 将 ContentTextStyle 转换为 ContentTextStyleV1 +func (s *ContentTextStyle) ToV1() *ContentTextStyleV1 { + if s == nil { + return nil + } + return &ContentTextStyleV1{ + Bold: s.Bold, + StrikeThrough: s.StrikeThrough, + BackColor: s.BackColor, + TextColor: s.TextColor, + Link: s.Link, + } +} + +// ToV1 将 ContentTextRun 转换为 ContentTextRunV1 +func (t *ContentTextRun) ToV1() *ContentTextRunV1 { + if t == nil { + return nil + } + return &ContentTextRunV1{ + Text: t.Text, + Style: t.Style.ToV1(), + } +} + +// ToV1 将 ContentMention 转换为 ContentPersonV1 +func (m *ContentMention) ToV1() *ContentPersonV1 { + if m == nil { + return nil + } + return &ContentPersonV1{ + OpenID: m.UserID, + } +} + +// ========== ContentBlockV1 转 ContentBlock ========== + +// ToV2 将 ContentBlockV1 转换为 ContentBlock +func (c *ContentBlockV1) ToV2() *ContentBlock { + if c == nil { + return nil + } + result := &ContentBlock{} + for _, block := range c.Blocks { + result.Blocks = append(result.Blocks, block.ToV2()) + } + return result +} + +// ToV2 将 ContentBlockElementV1 转换为 ContentBlockElement +func (e *ContentBlockElementV1) ToV2() ContentBlockElement { + return ContentBlockElement{ + BlockElementType: e.Type, + Paragraph: e.Paragraph.ToV2(), + Gallery: e.Gallery.ToV2(), + } +} + +// ToV2 将 ContentGalleryV1 转换为 ContentGallery +func (g *ContentGalleryV1) ToV2() *ContentGallery { + if g == nil { + return nil + } + images := make([]ContentImageItem, 0, len(g.ImageList)) + for _, img := range g.ImageList { + images = append(images, img.ToV2()) + } + return &ContentGallery{ + Images: images, + } +} + +// ToV2 将 ContentImageItemV1 转换为 ContentImageItem +func (i *ContentImageItemV1) ToV2() ContentImageItem { + return ContentImageItem{ + FileToken: i.FileToken, + Src: i.Src, + Width: i.Width, + Height: i.Height, + } +} + +// ToV2 将 ContentParagraphV1 转换为 ContentParagraph +func (p *ContentParagraphV1) ToV2() *ContentParagraph { + if p == nil { + return nil + } + result := &ContentParagraph{ + Style: p.Style.ToV2(), + } + for _, elem := range p.Elements { + result.Elements = append(result.Elements, elem.ToV2()) + } + return result +} + +// ToV2 将 ContentParagraphElementV1 转换为 ContentParagraphElement +func (e *ContentParagraphElementV1) ToV2() ContentParagraphElement { + t := ParagraphElementTypeTextRun + if e.Type != nil { + t = e.Type.ToV2() + } + return ContentParagraphElement{ + ParagraphElementType: t.Ptr(), + TextRun: e.TextRun.ToV2(), + DocsLink: e.DocsLink, + Mention: e.Person.ToV2(), + } +} + +// ToV2 将 ContentParagraphStyleV1 转换为 ContentParagraphStyle +func (s *ContentParagraphStyleV1) ToV2() *ContentParagraphStyle { + if s == nil { + return nil + } + return &ContentParagraphStyle{ + List: s.List.ToV2(), + } +} + +// ToV2 将 ContentListV1 转换为 ContentList +func (l *ContentListV1) ToV2() *ContentList { + if l == nil { + return nil + } + return &ContentList{ + ListType: l.Type, + IndentLevel: l.IndentLevel, + Number: l.Number, + } +} + +// ToV2 将 ContentTextStyleV1 转换为 ContentTextStyle +func (s *ContentTextStyleV1) ToV2() *ContentTextStyle { + if s == nil { + return nil + } + return &ContentTextStyle{ + Bold: s.Bold, + StrikeThrough: s.StrikeThrough, + BackColor: s.BackColor, + TextColor: s.TextColor, + Link: s.Link, + } +} + +// ToV2 将 ContentTextRunV1 转换为 ContentTextRun +func (t *ContentTextRunV1) ToV2() *ContentTextRun { + if t == nil { + return nil + } + return &ContentTextRun{ + Text: t.Text, + Style: t.Style.ToV2(), + } +} + +// ToV2 将 ContentPersonV1 转换为 ContentMention +func (p *ContentPersonV1) ToV2() *ContentMention { + if p == nil { + return nil + } + return &ContentMention{ + UserID: p.OpenID, + } +} + +// ProgressRateV1 进度率 +type ProgressRateV1 struct { + Percent *float64 `json:"percent,omitempty"` + Status *int32 `json:"status,omitempty"` +} + +// ProgressV1 进展记录 +type ProgressV1 struct { + ID string `json:"progress_id"` + ModifyTime string `json:"modify_time"` + Content *ContentBlockV1 `json:"content,omitempty"` + ProgressRate *ProgressRateV1 `json:"progress_rate,omitempty"` +} + +// ToResp converts ProgressV1 to RespProgress +func (p *ProgressV1) ToResp() *RespProgress { + if p == nil { + return nil + } + resp := &RespProgress{ + ID: p.ID, + ModifyTime: formatTimestamp(p.ModifyTime), + } + if p.ProgressRate != nil { + resp.ProgressRate = &RespProgressRate{ + Percent: p.ProgressRate.Percent, + } + if p.ProgressRate.Status != nil { + s := ProgressStatus(*p.ProgressRate.Status).String() + if s != "" { + resp.ProgressRate.Status = &s + } + } + } + // Convert ContentBlockV1 to ContentBlock, then serialize to JSON string + if p.Content != nil && len(p.Content.Blocks) > 0 { + if v2 := p.Content.ToV2(); v2 != nil && len(v2.Blocks) > 0 { + if bytes, err := json.Marshal(v2); err == nil { + s := string(bytes) + resp.Content = &s + } + } + } + return resp +} + +// int32Ptr returns a pointer to the given int32 value. +func int32Ptr(v int32) *int32 { return &v } + +// ========== Progress (for OKR v2 API ListOkrObjectiveProgress/ListOkrKeyResultProgress) ========== + +// ProgressRate 进度率(v2 API) +type ProgressRate struct { + ProgressPercent *float64 `json:"progress_percent,omitempty"` + ProgressStatus *int32 `json:"progress_status,omitempty"` +} + +// Progress 进展记录(v2 API) +type Progress struct { + ID string `json:"id"` + CreateTime string `json:"create_time"` + UpdateTime string `json:"update_time"` + Owner Owner `json:"owner"` + EntityType *int32 `json:"entity_type,omitempty"` + EntityID string `json:"entity_id"` + Content *ContentBlock `json:"content,omitempty"` + ProgressRate *ProgressRate `json:"progress_rate,omitempty"` +} + +// ToResp converts Progress to RespProgress +func (p *Progress) ToResp() *RespProgress { + if p == nil { + return nil + } + cteateTime := formatTimestamp(p.CreateTime) + resp := &RespProgress{ + ID: p.ID, + ModifyTime: formatTimestamp(p.UpdateTime), // Use UpdateTime as ModifyTime + CreateTime: &cteateTime, + } + if p.ProgressRate != nil { + resp.ProgressRate = &RespProgressRate{ + Percent: p.ProgressRate.ProgressPercent, + } + if p.ProgressRate.ProgressStatus != nil { + s := ProgressStatus(*p.ProgressRate.ProgressStatus).String() + if s != "" { + resp.ProgressRate.Status = &s + } + } + } + // Serialize ContentBlock to JSON string + if p.Content != nil && len(p.Content.Blocks) > 0 { + if bytes, err := json.Marshal(p.Content); err == nil { + s := string(bytes) + resp.Content = &s + } + } + return resp +} diff --git a/shortcuts/okr/okr_openapi_test.go b/shortcuts/okr/okr_openapi_test.go index a2f156a1b..d123dc92f 100644 --- a/shortcuts/okr/okr_openapi_test.go +++ b/shortcuts/okr/okr_openapi_test.go @@ -4,8 +4,10 @@ package okr import ( + "encoding/json" "testing" + "github.com/larksuite/cli/internal/core" "github.com/smartystreets/goconvey/convey" ) @@ -35,6 +37,7 @@ func TestToRespMethods(t *testing.T) { convey.So((*KeyResult)(nil).ToResp(), convey.ShouldBeNil) convey.So((*Objective)(nil).ToResp(), convey.ShouldBeNil) convey.So((*Owner)(nil).ToResp(), convey.ShouldBeNil) + convey.So((*ProgressV1)(nil).ToResp(), convey.ShouldBeNil) }) convey.Convey("ToResp methods work with valid objects", t, func() { @@ -129,14 +132,391 @@ func TestToRespMethods(t *testing.T) { convey.So(*resp.Score, convey.ShouldEqual, 0.9) convey.So(*resp.Content, convey.ShouldNotBeEmpty) }) + + convey.Convey("ProgressV1", func() { + record := &ProgressV1{ + ID: "progress-id", + ModifyTime: "1735776000000", + Content: &ContentBlockV1{ + Blocks: []ContentBlockElementV1{ + { + Type: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraphV1{ + Elements: []ContentParagraphElementV1{ + { + Type: ParagraphElementTypeV1TextRun.Ptr(), + TextRun: &ContentTextRunV1{ + Text: strPtr("Hello progress"), + }, + }, + }, + }, + }, + }, + }, + ProgressRate: &ProgressRateV1{ + Percent: float64Ptr(75.0), + Status: int32Ptr(0), + }, + } + resp := record.ToResp() + convey.So(resp, convey.ShouldNotBeNil) + convey.So(resp.ID, convey.ShouldEqual, "progress-id") + convey.So(resp.ModifyTime, convey.ShouldStartWith, "2025-01-02") + convey.So(resp.Content, convey.ShouldNotBeNil) + convey.So(*resp.Content, convey.ShouldContainSubstring, "Hello progress") + convey.So(resp.ProgressRate, convey.ShouldNotBeNil) + convey.So(*resp.ProgressRate.Percent, convey.ShouldEqual, 75.0) + }) + + convey.Convey("ProgressV1 with empty content", func() { + record := &ProgressV1{ + ID: "progress-id-2", + ModifyTime: "1735776000000", + } + resp := record.ToResp() + convey.So(resp, convey.ShouldNotBeNil) + convey.So(resp.Content, convey.ShouldBeNil) + convey.So(resp.ProgressRate, convey.ShouldBeNil) + }) + }) +} + +func TestContentBlockV1V2RoundTrip(t *testing.T) { + convey.Convey("ContentBlock V1↔V2 round-trip", t, func() { + original := &ContentBlock{ + Blocks: []ContentBlockElement{ + { + BlockElementType: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraph{ + Style: &ContentParagraphStyle{ + List: &ContentList{ + ListType: listTypePtr(ListTypeBullet), + IndentLevel: int32Ptr(1), + Number: int32Ptr(2), + }, + }, + Elements: []ContentParagraphElement{ + { + ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), + TextRun: &ContentTextRun{ + Text: strPtr("Hello world"), + Style: &ContentTextStyle{ + Bold: boolPtr(true), + StrikeThrough: boolPtr(false), + }, + }, + }, + { + ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(), + DocsLink: &ContentDocsLink{ + URL: strPtr("https://example.com"), + Title: strPtr("Example"), + }, + }, + { + ParagraphElementType: ParagraphElementTypeMention.Ptr(), + Mention: &ContentMention{ + UserID: strPtr("ou_123"), + }, + }, + }, + }, + }, + { + BlockElementType: BlockElementTypeGallery.Ptr(), + Gallery: &ContentGallery{ + Images: []ContentImageItem{ + {FileToken: strPtr("ftoken1"), Width: float64Ptr(100), Height: float64Ptr(200)}, + }, + }, + }, + }, + } + + // V2 -> V1 + v1 := original.ToV1() + convey.So(v1, convey.ShouldNotBeNil) + convey.So(len(v1.Blocks), convey.ShouldEqual, 2) + + // V1 -> V2 + v2 := v1.ToV2() + convey.So(v2, convey.ShouldNotBeNil) + convey.So(len(v2.Blocks), convey.ShouldEqual, 2) + + // Verify first block (paragraph) + convey.So(*v2.Blocks[0].BlockElementType, convey.ShouldEqual, BlockElementTypeParagraph) + convey.So(v2.Blocks[0].Paragraph, convey.ShouldNotBeNil) + convey.So(len(v2.Blocks[0].Paragraph.Elements), convey.ShouldEqual, 3) + + // TextRun + textRunElem := v2.Blocks[0].Paragraph.Elements[0] + convey.So(*textRunElem.ParagraphElementType, convey.ShouldEqual, ParagraphElementTypeTextRun) + convey.So(textRunElem.TextRun, convey.ShouldNotBeNil) + convey.So(*textRunElem.TextRun.Text, convey.ShouldEqual, "Hello world") + convey.So(textRunElem.TextRun.Style, convey.ShouldNotBeNil) + convey.So(*textRunElem.TextRun.Style.Bold, convey.ShouldBeTrue) + + // DocsLink + docsLinkElem := v2.Blocks[0].Paragraph.Elements[1] + convey.So(*docsLinkElem.ParagraphElementType, convey.ShouldEqual, ParagraphElementTypeDocsLink) + convey.So(docsLinkElem.DocsLink, convey.ShouldNotBeNil) + convey.So(*docsLinkElem.DocsLink.URL, convey.ShouldEqual, "https://example.com") + + // Mention + mentionElem := v2.Blocks[0].Paragraph.Elements[2] + convey.So(*mentionElem.ParagraphElementType, convey.ShouldEqual, ParagraphElementTypeMention) + convey.So(mentionElem.Mention, convey.ShouldNotBeNil) + convey.So(*mentionElem.Mention.UserID, convey.ShouldEqual, "ou_123") + + // Verify second block (gallery) + convey.So(*v2.Blocks[1].BlockElementType, convey.ShouldEqual, BlockElementTypeGallery) + convey.So(v2.Blocks[1].Gallery, convey.ShouldNotBeNil) + convey.So(len(v2.Blocks[1].Gallery.Images), convey.ShouldEqual, 1) + + // Verify list style round-trip + convey.So(v2.Blocks[0].Paragraph.Style, convey.ShouldNotBeNil) + convey.So(v2.Blocks[0].Paragraph.Style.List, convey.ShouldNotBeNil) + convey.So(*v2.Blocks[0].Paragraph.Style.List.ListType, convey.ShouldEqual, ListTypeBullet) + convey.So(*v2.Blocks[0].Paragraph.Style.List.IndentLevel, convey.ShouldEqual, 1) + }) + + convey.Convey("nil ContentBlock round-trip", t, func() { + convey.So((*ContentBlock)(nil).ToV1(), convey.ShouldBeNil) + convey.So((*ContentBlockV1)(nil).ToV2(), convey.ShouldBeNil) + }) +} + +func TestContentBlockV1JSON(t *testing.T) { + convey.Convey("ContentBlockV1 JSON serialization", t, func() { + v1 := &ContentBlockV1{ + Blocks: []ContentBlockElementV1{ + { + Type: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraphV1{ + Elements: []ContentParagraphElementV1{ + { + Type: ParagraphElementTypeV1TextRun.Ptr(), + TextRun: &ContentTextRunV1{Text: strPtr("test")}, + }, + }, + }, + }, + }, + } + data, err := json.Marshal(v1) + convey.So(err, convey.ShouldBeNil) + convey.So(string(data), convey.ShouldContainSubstring, "paragraph") + convey.So(string(data), convey.ShouldContainSubstring, "textRun") + convey.So(string(data), convey.ShouldContainSubstring, "test") + }) +} + +func TestProgressRecordToResp_ContentBlockV1Conversion(t *testing.T) { + convey.Convey("ProgressV1.ToResp converts V1 content to V2 JSON", t, func() { + record := &ProgressV1{ + ID: "rec-123", + ModifyTime: "1735776000000", + Content: &ContentBlockV1{ + Blocks: []ContentBlockElementV1{ + { + Type: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraphV1{ + Elements: []ContentParagraphElementV1{ + { + Type: ParagraphElementTypeV1TextRun.Ptr(), + TextRun: &ContentTextRunV1{Text: strPtr("V1 content")}, + }, + { + Type: ParagraphElementTypeV1Mention.Ptr(), + Person: &ContentPersonV1{OpenID: strPtr("ou_mention")}, + }, + }, + }, + }, + }, + }, + } + resp := record.ToResp() + convey.So(resp.Content, convey.ShouldNotBeNil) + // Content should be V2 format JSON string + convey.So(*resp.Content, convey.ShouldContainSubstring, "block_element_type") + convey.So(*resp.Content, convey.ShouldContainSubstring, "V1 content") + convey.So(*resp.Content, convey.ShouldContainSubstring, "user_id") + }) +} + +func TestParseProgressRecord(t *testing.T) { + convey.Convey("parseProgressRecord", t, func() { + convey.Convey("valid data", func() { + data := map[string]any{ + "progress_id": "123", + "modify_time": "1735776000000", + "content": map[string]any{ + "blocks": []any{ + map[string]any{ + "type": "paragraph", + "paragraph": map[string]any{ + "elements": []any{ + map[string]any{ + "type": "textRun", + "textRun": map[string]any{"text": "test"}, + }, + }, + }, + }, + }, + }, + } + record, err := parseProgressRecord(data) + convey.So(err, convey.ShouldBeNil) + convey.So(record.ID, convey.ShouldEqual, "123") + convey.So(record.Content, convey.ShouldNotBeNil) + }) + + convey.Convey("empty data", func() { + data := map[string]any{} + record, err := parseProgressRecord(data) + convey.So(err, convey.ShouldBeNil) + convey.So(record.ID, convey.ShouldEqual, "") + }) + }) +} + +func TestParseCreateProgressRecordParams_BrandAwareSourceURL(t *testing.T) { + convey.Convey("parseCreateProgressRecordParams brand-aware defaults", t, func() { + // This test directly tests the brand-aware default logic by constructing + // a minimal ContentBlock JSON and checking the resolved sourceURL. + convey.Convey("feishu brand defaults to feishu.cn", func() { + url := core.ResolveOpenBaseURL(core.BrandFeishu) + "/app" + convey.So(url, convey.ShouldEqual, "https://open.feishu.cn/app") + }) + convey.Convey("lark brand defaults to larksuite.com", func() { + url := core.ResolveOpenBaseURL(core.BrandLark) + "/app" + convey.So(url, convey.ShouldEqual, "https://open.larksuite.com/app") + }) + }) +} + +func TestProgressStatus(t *testing.T) { + convey.Convey("ProgressStatus parsing and string conversion", t, func() { + convey.Convey("ParseProgressStatus accepts string names", func() { + s, ok := ParseProgressStatus("normal") + convey.So(ok, convey.ShouldBeTrue) + convey.So(s, convey.ShouldEqual, ProgressStatusNormal) + + s, ok = ParseProgressStatus("overdue") + convey.So(ok, convey.ShouldBeTrue) + convey.So(s, convey.ShouldEqual, ProgressStatusOverdue) + + s, ok = ParseProgressStatus("done") + convey.So(ok, convey.ShouldBeTrue) + convey.So(s, convey.ShouldEqual, ProgressStatusDone) + }) + + convey.Convey("ParseProgressStatus accepts numeric strings", func() { + s, ok := ParseProgressStatus("0") + convey.So(ok, convey.ShouldBeTrue) + convey.So(s, convey.ShouldEqual, ProgressStatusNormal) + + s, ok = ParseProgressStatus("1") + convey.So(ok, convey.ShouldBeTrue) + convey.So(s, convey.ShouldEqual, ProgressStatusOverdue) + + s, ok = ParseProgressStatus("2") + convey.So(ok, convey.ShouldBeTrue) + convey.So(s, convey.ShouldEqual, ProgressStatusDone) + }) + + convey.Convey("ParseProgressStatus rejects invalid values", func() { + _, ok := ParseProgressStatus("invalid") + convey.So(ok, convey.ShouldBeFalse) + }) + + convey.Convey("String returns human-readable names", func() { + convey.So(ProgressStatusNormal.String(), convey.ShouldEqual, "normal") + convey.So(ProgressStatusOverdue.String(), convey.ShouldEqual, "overdue") + convey.So(ProgressStatusDone.String(), convey.ShouldEqual, "done") + }) + }) +} + +func TestProgressV1ToResp_StatusConversion(t *testing.T) { + convey.Convey("ProgressV1.ToResp converts Status int to string", t, func() { + convey.Convey("status=0 → normal", func() { + record := &ProgressV1{ + ID: "rec-1", + ModifyTime: "1735776000000", + ProgressRate: &ProgressRateV1{ + Percent: float64Ptr(50.0), + Status: int32Ptr(0), + }, + } + resp := record.ToResp() + convey.So(resp.ProgressRate, convey.ShouldNotBeNil) + convey.So(*resp.ProgressRate.Status, convey.ShouldEqual, "normal") + convey.So(*resp.ProgressRate.Percent, convey.ShouldEqual, 50.0) + }) + + convey.Convey("status=1 → overdue", func() { + record := &ProgressV1{ + ID: "rec-2", + ModifyTime: "1735776000000", + ProgressRate: &ProgressRateV1{ + Percent: float64Ptr(30.0), + Status: int32Ptr(1), + }, + } + resp := record.ToResp() + convey.So(*resp.ProgressRate.Status, convey.ShouldEqual, "overdue") + }) + + convey.Convey("status=2 → done", func() { + record := &ProgressV1{ + ID: "rec-3", + ModifyTime: "1735776000000", + ProgressRate: &ProgressRateV1{ + Percent: float64Ptr(100.0), + Status: int32Ptr(2), + }, + } + resp := record.ToResp() + convey.So(*resp.ProgressRate.Status, convey.ShouldEqual, "done") + }) + + convey.Convey("nil ProgressRate", func() { + record := &ProgressV1{ + ID: "rec-4", + ModifyTime: "1735776000000", + } + resp := record.ToResp() + convey.So(resp.ProgressRate, convey.ShouldBeNil) + }) + + convey.Convey("nil Status in ProgressRate", func() { + record := &ProgressV1{ + ID: "rec-5", + ModifyTime: "1735776000000", + ProgressRate: &ProgressRateV1{ + Percent: float64Ptr(75.0), + }, + } + resp := record.ToResp() + convey.So(resp.ProgressRate, convey.ShouldNotBeNil) + convey.So(resp.ProgressRate.Status, convey.ShouldBeNil) + convey.So(*resp.ProgressRate.Percent, convey.ShouldEqual, 75.0) + }) }) } // strPtr returns a pointer to the given string value. func strPtr(v string) *string { return &v } -// int32Ptr returns a pointer to the given int32 value. -func int32Ptr(v int32) *int32 { return &v } - // float64Ptr returns a pointer to the given float64 value. func float64Ptr(v float64) *float64 { return &v } + +// boolPtr returns a pointer to the given bool value. +func boolPtr(v bool) *bool { return &v } + +// listTypePtr returns a pointer to the given ListType value. +func listTypePtr(v ListType) *ListType { return &v } diff --git a/shortcuts/okr/okr_progress_create.go b/shortcuts/okr/okr_progress_create.go new file mode 100644 index 000000000..6f5baac7f --- /dev/null +++ b/shortcuts/okr/okr_progress_create.go @@ -0,0 +1,235 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "strconv" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// targetTypeAllowed values for --target-type flag +var targetTypeAllowed = map[string]int{ + "objective": 2, + "key_result": 3, +} + +// createProgressRecordParams holds the parsed parameters for creating a progress. +type createProgressRecordParams struct { + ContentV1 *ContentBlockV1 + TargetID string + TargetType int + SourceTitle string + SourceURL string + ProgressRate *ProgressRateV1 + UserIDType string +} + +// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters. +func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) { + content := runtime.Str("content") + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return nil, common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err) + } + contentV1 := cb.ToV1() + + targetType := runtime.Str("target-type") + targetTypeVal := targetTypeAllowed[targetType] + + sourceTitle := runtime.Str("source-title") + if sourceTitle == "" { + sourceTitle = "created by lark-cli" + } + + sourceURL := runtime.Str("source-url") + if sourceURL == "" { + sourceURL = core.ResolveOpenBaseURL(runtime.Config.Brand) + "/app" + } + + var progressRate *ProgressRateV1 + if v := runtime.Str("progress-percent"); v != "" { + percent, err := strconv.ParseFloat(v, 64) + if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 { + return nil, common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999") + } + progressRate = &ProgressRateV1{Percent: &percent} + if s := runtime.Str("progress-status"); s != "" { + status, ok := ParseProgressStatus(s) + if !ok { + return nil, common.FlagErrorf("--progress-status must be one of: normal | overdue | done") + } + progressRate.Status = int32Ptr(int32(status)) + } + } + + return &createProgressRecordParams{ + ContentV1: contentV1, + TargetID: runtime.Str("target-id"), + TargetType: targetTypeVal, + SourceTitle: sourceTitle, + SourceURL: sourceURL, + ProgressRate: progressRate, + UserIDType: runtime.Str("user-id-type"), + }, nil +} + +// OKRCreateProgressRecord creates a progress. +var OKRCreateProgressRecord = common.Shortcut{ + Service: "okr", + Command: "+progress-create", + Description: "Create an OKR progress", + Risk: "write", + Scopes: []string{"okr:okr.progress:writeonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true}, + {Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}}, + {Name: "progress-percent", Desc: "progress percentage"}, + {Name: "progress-status", Desc: "progress status: normal | overdue | done. must provided with --progress-percent", Enum: []string{"normal", "overdue", "done"}}, + {Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"}, + {Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"}, + {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + content := runtime.Str("content") + if content == "" { + return common.FlagErrorf("--content is required") + } + if err := validate.RejectControlChars(content, "content"); err != nil { + return err + } + // Validate content is valid JSON and can be parsed as ContentBlock + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err) + } + + targetID := runtime.Str("target-id") + if targetID == "" { + return common.FlagErrorf("--target-id is required") + } + if err := validate.RejectControlChars(targetID, "target-id"); err != nil { + return err + } + if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 { + return common.FlagErrorf("--target-id must be a positive int64") + } + + targetType := runtime.Str("target-type") + if _, ok := targetTypeAllowed[targetType]; !ok { + return common.FlagErrorf("--target-type must be one of: objective | key_result") + } + + if v := runtime.Str("source-title"); v != "" { + if err := validate.RejectControlChars(v, "source-title"); err != nil { + return err + } + } + if v := runtime.Str("source-url"); v != "" { + if err := validate.RejectControlChars(v, "source-url"); err != nil { + return err + } + } + + if v := runtime.Str("progress-percent"); v != "" { + percent, err := strconv.ParseFloat(v, 64) + if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 { + return common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999") + } + } + if v := runtime.Str("progress-status"); v != "" { + if _, ok := ParseProgressStatus(v); !ok { + return common.FlagErrorf("--progress-status must be one of: normal | overdue | done") + } + if v := runtime.Str("progress-percent"); v == "" { + return common.FlagErrorf("--progress-percent must provided with --progress-status") + } + } + + idType := runtime.Str("user-id-type") + if idType != "open_id" && idType != "union_id" && idType != "user_id" { + return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + p, _ := parseCreateProgressRecordParams(runtime) + params := map[string]interface{}{ + "user_id_type": p.UserIDType, + } + body := map[string]interface{}{ + "content": p.ContentV1, + "target_id": p.TargetID, + "target_type": p.TargetType, + "source_title": p.SourceTitle, + "source_url": p.SourceURL, + } + if p.ProgressRate != nil { + body["progress_rate"] = p.ProgressRate + } + return common.NewDryRunAPI(). + POST("/open-apis/okr/v1/progress_records/"). + Params(params). + Body(body). + Desc(fmt.Sprintf("Create OKR progress for %s", runtime.Str("target-type"))) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + p, err := parseCreateProgressRecordParams(runtime) + if err != nil { + return err + } + + body := map[string]interface{}{ + "content": p.ContentV1, + "target_id": p.TargetID, + "target_type": p.TargetType, + "source_title": p.SourceTitle, + "source_url": p.SourceURL, + } + if p.ProgressRate != nil { + body["progress_rate"] = p.ProgressRate + } + + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", p.UserIDType) + + data, err := runtime.DoAPIJSON("POST", "/open-apis/okr/v1/progress_records/", queryParams, body) + if err != nil { + return err + } + + record, err := parseProgressRecord(data) + if err != nil { + return err + } + + resp := record.ToResp() + result := map[string]interface{}{ + "progress": resp, + } + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", *resp.Content) + } + }) + return nil + }, +} diff --git a/shortcuts/okr/okr_progress_create_test.go b/shortcuts/okr/okr_progress_create_test.go new file mode 100644 index 000000000..87b266612 --- /dev/null +++ b/shortcuts/okr/okr_progress_create_test.go @@ -0,0 +1,339 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +func progressCreateTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + return &core.CliConfig{ + AppID: "test-okr-progress-create", + AppSecret: "secret-okr-progress-create", + Brand: core.BrandFeishu, + } +} + +func runProgressCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + parent := &cobra.Command{Use: "okr"} + OKRCreateProgressRecord.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}` + +// --- Validate tests --- + +func TestProgressCreateValidate_MissingContent(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for missing --content") + } +} + +func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", "not-json", + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for invalid --content JSON") + } + if !strings.Contains(err.Error(), "--content must be valid ContentBlock JSON") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateValidate_MissingTargetID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for missing --target-id") + } +} + +func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-id", "abc", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for non-numeric --target-id") + } + if !strings.Contains(err.Error(), "--target-id must be a positive int64") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateValidate_InvalidTargetType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-id", "123", + "--target-type", "invalid", + }) + if err == nil { + t.Fatal("expected error for invalid --target-type") + } + if !strings.Contains(err.Error(), "--target-type") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}", + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for control chars in --content") + } +} + +func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-id", "123", + "--target-type", "objective", + "--user-id-type", "invalid", + }) + if err == nil { + t.Fatal("expected error for invalid --user-id-type") + } +} + +func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-id", "123", + "--target-type", "objective", + "--progress-percent", "999999999999", + }) + if err == nil { + t.Fatal("expected error for --progress-percent > 100") + } + if !strings.Contains(err.Error(), "--progress-percent must be a number between -99999999999 and 99999999999") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-id", "123", + "--target-type", "objective", + "--progress-percent", "abc", + }) + if err == nil { + t.Fatal("expected error for non-numeric --progress-percent") + } + if !strings.Contains(err.Error(), "--progress-percent") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-id", "123", + "--target-type", "objective", + "--progress-status", "invalid_status", + }) + if err == nil { + t.Fatal("expected error for invalid --progress-status") + } + if !strings.Contains(err.Error(), "--progress-status") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateValidate_Valid(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/progress_records/", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "100", + "modify_time": "1735776000000", + }, + }, + }) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-id", "123", + "--target-type", "objective", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- DryRun tests --- + +func TestProgressCreateDryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-id", "123", + "--target-type", "objective", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "/open-apis/okr/v1/progress_records/") { + t.Fatalf("dry-run output should contain API path, got: %s", output) + } + if !strings.Contains(output, "POST") { + t.Fatalf("dry-run output should contain POST method, got: %s", output) + } + // Verify body contains content and target info + if !strings.Contains(output, "target_id") { + t.Fatalf("dry-run output should contain target_id, got: %s", output) + } + if !strings.Contains(output, "source_url") { + t.Fatalf("dry-run output should contain source_url (brand default), got: %s", output) + } +} + +func TestProgressCreateDryRun_WithProgressRate(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-id", "123", + "--target-type", "objective", + "--progress-percent", "75", + "--progress-status", "done", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "progress_rate") { + t.Fatalf("dry-run output should contain progress_rate, got: %s", output) + } +} + +// --- Execute tests --- + +func TestProgressCreateExecute_Success(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/progress_records/", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "200", + "modify_time": "1735776000000", + }, + }, + }) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-id", "456", + "--target-type", "key_result", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + pr, _ := data["progress"].(map[string]interface{}) + if pr == nil { + t.Fatal("expected progress in output") + } + if pr["progress_id"] != "200" { + t.Fatalf("progress_id = %v, want 200", pr["progress_id"]) + } +} + +func TestProgressCreateExecute_APIError(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/progress_records/", + Status: 400, + Body: map[string]interface{}{ + "code": 1001001, + "msg": "invalid parameters", + }, + }) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validContentBlockJSON, + "--target-id", "789", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for API failure") + } +} diff --git a/shortcuts/okr/okr_progress_delete.go b/shortcuts/okr/okr_progress_delete.go new file mode 100644 index 000000000..5738a116c --- /dev/null +++ b/shortcuts/okr/okr_progress_delete.go @@ -0,0 +1,64 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "fmt" + "io" + "strconv" + + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// OKRDeleteProgressRecord deletes a progress by ID. +var OKRDeleteProgressRecord = common.Shortcut{ + Service: "okr", + Command: "+progress-delete", + Description: "Delete an OKR progress by ID", + Risk: "high-risk-write", + Scopes: []string{"okr:okr.progress:delete"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "progress-id", Desc: "progress ID (int64)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + progressID := runtime.Str("progress-id") + if progressID == "" { + return common.FlagErrorf("--progress-id is required") + } + if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 { + return common.FlagErrorf("--progress-id must be a positive int64") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + progressID := runtime.Str("progress-id") + return common.NewDryRunAPI(). + DELETE("/open-apis/okr/v1/progress_records/:progress_id"). + Set("progress_id", progressID). + Desc("Delete OKR progress") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + progressID := runtime.Str("progress-id") + + path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", progressID) + _, err := runtime.DoAPIJSON("DELETE", path, larkcore.QueryParams{}, nil) + if err != nil { + return err + } + + result := map[string]interface{}{ + "deleted": true, + "progress_id": progressID, + } + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Deleted progress record %s\n", progressID) + }) + return nil + }, +} diff --git a/shortcuts/okr/okr_progress_delete_test.go b/shortcuts/okr/okr_progress_delete_test.go new file mode 100644 index 000000000..ea25f1e6b --- /dev/null +++ b/shortcuts/okr/okr_progress_delete_test.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +func progressDeleteTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + return &core.CliConfig{ + AppID: "test-okr-progress-delete", + AppSecret: "secret-okr-progress-delete", + Brand: core.BrandFeishu, + } +} + +func runProgressDeleteShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + parent := &cobra.Command{Use: "okr"} + OKRDeleteProgressRecord.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// --- Validate tests --- + +func TestProgressDeleteValidate_MissingProgressID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressDeleteTestConfig(t)) + err := runProgressDeleteShortcut(t, f, stdout, []string{"+progress-delete"}) + if err == nil { + t.Fatal("expected error for missing --progress-id") + } +} + +func TestProgressDeleteValidate_InvalidProgressID_NonNumeric(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressDeleteTestConfig(t)) + err := runProgressDeleteShortcut(t, f, stdout, []string{"+progress-delete", "--progress-id", "abc"}) + if err == nil { + t.Fatal("expected error for non-numeric --progress-id") + } + if !strings.Contains(err.Error(), "--progress-id must be a positive int64") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressDeleteValidate_InvalidProgressID_Zero(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressDeleteTestConfig(t)) + err := runProgressDeleteShortcut(t, f, stdout, []string{"+progress-delete", "--progress-id", "0"}) + if err == nil { + t.Fatal("expected error for zero --progress-id") + } +} + +func TestProgressDeleteValidate_InvalidProgressID_Negative(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressDeleteTestConfig(t)) + err := runProgressDeleteShortcut(t, f, stdout, []string{"+progress-delete", "--progress-id", "-1"}) + if err == nil { + t.Fatal("expected error for negative --progress-id") + } +} + +func TestProgressDeleteValidate_Valid(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressDeleteTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/okr/v1/progress_records/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + err := runProgressDeleteShortcut(t, f, stdout, []string{"+progress-delete", "--progress-id", "123", "--yes"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- DryRun tests --- + +func TestProgressDeleteDryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressDeleteTestConfig(t)) + err := runProgressDeleteShortcut(t, f, stdout, []string{ + "+progress-delete", + "--progress-id", "456", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "456") { + t.Fatalf("dry-run output should contain progress-id 456, got: %s", output) + } + if !strings.Contains(output, "/open-apis/okr/v1/progress_records/456") { + t.Fatalf("dry-run output should contain API path, got: %s", output) + } + if !strings.Contains(output, "DELETE") { + t.Fatalf("dry-run output should contain DELETE method, got: %s", output) + } +} + +// --- Execute tests --- + +func TestProgressDeleteExecute_Success(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressDeleteTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/okr/v1/progress_records/789", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + err := runProgressDeleteShortcut(t, f, stdout, []string{"+progress-delete", "--progress-id", "789", "--yes"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + if data["deleted"] != true { + t.Fatalf("deleted = %v, want true", data["deleted"]) + } + if data["progress_id"] != "789" { + t.Fatalf("progress_id = %v, want 789", data["progress_id"]) + } +} + +func TestProgressDeleteExecute_APIError(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressDeleteTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/okr/v1/progress_records/999", + Status: 500, + Body: map[string]interface{}{ + "code": 999, + "msg": "internal error", + }, + }) + err := runProgressDeleteShortcut(t, f, stdout, []string{"+progress-delete", "--progress-id", "999", "--yes"}) + if err == nil { + t.Fatal("expected error for API failure") + } +} diff --git a/shortcuts/okr/okr_progress_get.go b/shortcuts/okr/okr_progress_get.go new file mode 100644 index 000000000..0504e484f --- /dev/null +++ b/shortcuts/okr/okr_progress_get.go @@ -0,0 +1,103 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// OKRGetProgressRecord gets a progress by ID. +var OKRGetProgressRecord = common.Shortcut{ + Service: "okr", + Command: "+progress-get", + Description: "Get an OKR progress by ID", + Risk: "read", + Scopes: []string{"okr:okr.progress:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "progress-id", Desc: "progress ID (int64)", Required: true}, + {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + progressID := runtime.Str("progress-id") + if progressID == "" { + return common.FlagErrorf("--progress-id is required") + } + if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 { + return common.FlagErrorf("--progress-id must be a positive int64") + } + idType := runtime.Str("user-id-type") + if idType != "open_id" && idType != "union_id" && idType != "user_id" { + return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + progressID := runtime.Str("progress-id") + params := map[string]interface{}{ + "user_id_type": runtime.Str("user-id-type"), + } + return common.NewDryRunAPI(). + GET("/open-apis/okr/v1/progress_records/:progress_id"). + Params(params). + Set("progress_id", progressID). + Desc("Get OKR progress") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + progressID := runtime.Str("progress-id") + userIDType := runtime.Str("user-id-type") + + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", userIDType) + + path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", progressID) + data, err := runtime.DoAPIJSON("GET", path, queryParams, nil) + if err != nil { + return err + } + + record, err := parseProgressRecord(data) + if err != nil { + return err + } + + resp := record.ToResp() + result := map[string]interface{}{ + "progress": resp, + } + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Progress [%s]\n", resp.ID) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", *resp.Content) + } + }) + return nil + }, +} + +// parseProgressRecord parses a single progress from API response data. +func parseProgressRecord(data map[string]any) (*ProgressV1, error) { + raw, err := json.Marshal(data) + if err != nil { + return nil, err + } + var record ProgressV1 + if err := json.Unmarshal(raw, &record); err != nil { + return nil, err + } + return &record, nil +} diff --git a/shortcuts/okr/okr_progress_get_test.go b/shortcuts/okr/okr_progress_get_test.go new file mode 100644 index 000000000..edc24837b --- /dev/null +++ b/shortcuts/okr/okr_progress_get_test.go @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +func progressGetTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + return &core.CliConfig{ + AppID: "test-okr-progress-get", + AppSecret: "secret-okr-progress-get", + Brand: core.BrandFeishu, + } +} + +func runProgressGetShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + parent := &cobra.Command{Use: "okr"} + OKRGetProgressRecord.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// --- Validate tests --- + +func TestProgressGetValidate_MissingProgressID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressGetTestConfig(t)) + err := runProgressGetShortcut(t, f, stdout, []string{"+progress-get"}) + if err == nil { + t.Fatal("expected error for missing --progress-id") + } + if !strings.Contains(err.Error(), "progress-id") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressGetValidate_InvalidProgressID_NonNumeric(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressGetTestConfig(t)) + err := runProgressGetShortcut(t, f, stdout, []string{"+progress-get", "--progress-id", "abc"}) + if err == nil { + t.Fatal("expected error for non-numeric --progress-id") + } + if !strings.Contains(err.Error(), "--progress-id must be a positive int64") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressGetValidate_InvalidProgressID_Zero(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressGetTestConfig(t)) + err := runProgressGetShortcut(t, f, stdout, []string{"+progress-get", "--progress-id", "0"}) + if err == nil { + t.Fatal("expected error for zero --progress-id") + } +} + +func TestProgressGetValidate_InvalidUserIDType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressGetTestConfig(t)) + err := runProgressGetShortcut(t, f, stdout, []string{"+progress-get", "--progress-id", "123", "--user-id-type", "invalid"}) + if err == nil { + t.Fatal("expected error for invalid --user-id-type") + } + if !strings.Contains(err.Error(), "--user-id-type must be one of") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressGetValidate_Valid(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressGetTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v1/progress_records/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "123", + "modify_time": "1735776000000", + }, + }, + }) + err := runProgressGetShortcut(t, f, stdout, []string{"+progress-get", "--progress-id", "123"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- DryRun tests --- + +func TestProgressGetDryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressGetTestConfig(t)) + err := runProgressGetShortcut(t, f, stdout, []string{ + "+progress-get", + "--progress-id", "456", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "456") { + t.Fatalf("dry-run output should contain progress-id 456, got: %s", output) + } + if !strings.Contains(output, "/open-apis/okr/v1/progress_records/456") { + t.Fatalf("dry-run output should contain API path, got: %s", output) + } +} + +// --- Execute tests --- + +func TestProgressGetExecute_Success(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressGetTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v1/progress_records/789", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "789", + "modify_time": "1735776000000", + "content": map[string]interface{}{ + "blocks": []interface{}{ + map[string]interface{}{ + "type": "paragraph", + "paragraph": map[string]interface{}{ + "elements": []interface{}{ + map[string]interface{}{ + "type": "textRun", + "textRun": map[string]interface{}{"text": "ProgressV1 update"}, + }, + }, + }, + }, + }, + }, + }, + }, + }) + err := runProgressGetShortcut(t, f, stdout, []string{"+progress-get", "--progress-id", "789"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + pr, _ := data["progress"].(map[string]interface{}) + if pr == nil { + t.Fatal("expected progress in output") + } + if pr["progress_id"] != "789" { + t.Fatalf("progress_id = %v, want 789", pr["progress_id"]) + } +} + +func TestProgressGetExecute_APIError(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressGetTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v1/progress_records/999", + Status: 500, + Body: map[string]interface{}{ + "code": 999, + "msg": "internal error", + }, + }) + err := runProgressGetShortcut(t, f, stdout, []string{"+progress-get", "--progress-id", "999"}) + if err == nil { + t.Fatal("expected error for API failure") + } +} diff --git a/shortcuts/okr/okr_progress_list.go b/shortcuts/okr/okr_progress_list.go new file mode 100644 index 000000000..dbda3f8e6 --- /dev/null +++ b/shortcuts/okr/okr_progress_list.go @@ -0,0 +1,164 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// OKRListProgress lists progress for an objective or key result. +var OKRListProgress = common.Shortcut{ + Service: "okr", + Command: "+progress-list", + Description: "List progress for an objective or key result", + Risk: "read", + Scopes: []string{"okr:okr.progress:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true}, + {Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}}, + {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + {Name: "department-id-type", Default: "open_department_id", Desc: "department ID type: department_id | open_department_id"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + targetID := runtime.Str("target-id") + if targetID == "" { + return common.FlagErrorf("--target-id is required") + } + if err := validate.RejectControlChars(targetID, "target-id"); err != nil { + return err + } + if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 { + return common.FlagErrorf("--target-id must be a positive int64") + } + + targetType := runtime.Str("target-type") + if _, ok := targetTypeAllowed[targetType]; !ok { + return common.FlagErrorf("--target-type must be one of: objective | key_result") + } + + idType := runtime.Str("user-id-type") + if idType != "open_id" && idType != "union_id" && idType != "user_id" { + return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id") + } + + deptIDType := runtime.Str("department-id-type") + if deptIDType != "department_id" && deptIDType != "open_department_id" { + return common.FlagErrorf("--department-id-type must be one of: department_id | open_department_id") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + targetID := runtime.Str("target-id") + targetType := runtime.Str("target-type") + params := map[string]interface{}{ + "user_id_type": runtime.Str("user-id-type"), + "department_id_type": runtime.Str("department-id-type"), + "page_size": 100, + } + + switch targetType { + case "objective": + return common.NewDryRunAPI(). + GET("/open-apis/okr/v2/objectives/:objective_id/progresses"). + Params(params). + Set("objective_id", targetID). + Desc("List progresses for objective") + case "key_result": + return common.NewDryRunAPI(). + GET("/open-apis/okr/v2/key_results/:key_result_id/progresses"). + Params(params). + Set("key_result_id", targetID). + Desc("List progresses for key result") + } + return nil + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + targetID := runtime.Str("target-id") + targetType := runtime.Str("target-type") + userIDType := runtime.Str("user-id-type") + deptIDType := runtime.Str("department-id-type") + + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", userIDType) + queryParams.Set("department_id_type", deptIDType) + queryParams.Set("page_size", "100") + + var apiPath string + switch targetType { + case "objective": + apiPath = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/progresses", targetID) + case "key_result": + apiPath = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/progresses", targetID) + } + + var allProgress []*Progress + for { + if err := ctx.Err(); err != nil { + return err + } + + data, err := runtime.DoAPIJSON("GET", apiPath, queryParams, nil) + if err != nil { + return err + } + + itemsRaw, _ := data["items"].([]interface{}) + for _, item := range itemsRaw { + raw, err := json.Marshal(item) + if err != nil { + continue + } + var progress Progress + if err := json.Unmarshal(raw, &progress); err != nil { + continue + } + allProgress = append(allProgress, &progress) + } + + hasMore, pageToken := common.PaginationMeta(data) + if !hasMore || pageToken == "" { + break + } + queryParams.Set("page_token", pageToken) + } + + // Convert to response format + respProgress := make([]*RespProgress, 0, len(allProgress)) + for _, p := range allProgress { + respProgress = append(respProgress, p.ToResp()) + } + + runtime.OutFormat(map[string]interface{}{ + "progress_list": respProgress, + "total": len(respProgress), + }, nil, func(w io.Writer) { + fmt.Fprintf(w, "Found %d progress(es)\n", len(respProgress)) + for _, p := range respProgress { + fmt.Fprintf(w, " [%s] , %s", p.ID, p.ModifyTime) + if p.ProgressRate != nil && p.ProgressRate.Percent != nil { + fmt.Fprintf(w, " (%.2f%%", *p.ProgressRate.Percent) + if p.ProgressRate.Status != nil { + fmt.Fprintf(w, ", %s", *p.ProgressRate.Status) + } + fmt.Fprintf(w, ")\n") + if p.Content != nil { + fmt.Fprintf(w, " Content: %s\n", *p.Content) + } + } + fmt.Fprintln(w) + } + }) + return nil + }, +} diff --git a/shortcuts/okr/okr_progress_list_test.go b/shortcuts/okr/okr_progress_list_test.go new file mode 100644 index 000000000..0c5b01b60 --- /dev/null +++ b/shortcuts/okr/okr_progress_list_test.go @@ -0,0 +1,311 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +func progressListTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + return &core.CliConfig{ + AppID: "test-okr-progress-list", + AppSecret: "secret-okr-progress-list", + Brand: core.BrandFeishu, + } +} + +func runProgressListShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + parent := &cobra.Command{Use: "okr"} + OKRListProgress.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// --- Validate tests --- + +func TestProgressListValidate_MissingTargetID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t)) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for missing --target-id") + } +} + +func TestProgressListValidate_MissingTargetType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t)) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-id", "123", + }) + if err == nil { + t.Fatal("expected error for missing --target-type") + } +} + +func TestProgressListValidate_InvalidTargetID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t)) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-id", "abc", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for non-numeric --target-id") + } + if !strings.Contains(err.Error(), "--target-id must be a positive int64") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressListValidate_InvalidTargetType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t)) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-id", "123", + "--target-type", "invalid", + }) + if err == nil { + t.Fatal("expected error for invalid --target-type") + } + if !strings.Contains(err.Error(), "--target-type") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressListValidate_InvalidUserIDType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t)) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-id", "123", + "--target-type", "objective", + "--user-id-type", "invalid", + }) + if err == nil { + t.Fatal("expected error for invalid --user-id-type") + } +} + +func TestProgressListValidate_InvalidDepartmentIDType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t)) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-id", "123", + "--target-type", "objective", + "--department-id-type", "invalid", + }) + if err == nil { + t.Fatal("expected error for invalid --department-id-type") + } +} + +// --- DryRun tests --- + +func TestProgressListDryRun_Objective(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t)) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-id", "123456789", + "--target-type", "objective", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "/open-apis/okr/v2/objectives/123456789/progresses") { + t.Fatalf("dry-run output should contain objective API path, got: %s", output) + } + if !strings.Contains(output, "GET") { + t.Fatalf("dry-run output should contain GET method, got: %s", output) + } +} + +func TestProgressListDryRun_KeyResult(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t)) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-id", "987654321", + "--target-type", "key_result", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "/open-apis/okr/v2/key_results/987654321/progresses") { + t.Fatalf("dry-run output should contain key_result API path, got: %s", output) + } +} + +// --- Execute tests --- + +func TestProgressListExecute_Success_Objective(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressListTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v2/objectives/123456789/progresses", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "id": "111", + "create_time": "1735776000000", + "update_time": "1735776100000", + "owner": map[string]interface{}{"owner_type": "user", "user_id": "ou_test"}, + "entity_type": 2, + "entity_id": "123456789", + "content": map[string]interface{}{"blocks": []interface{}{}}, + "progress_rate": map[string]interface{}{ + "progress_percent": 50.0, + "progress_status": 0, + }, + }, + }, + "has_more": false, + }, + }, + }) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-id", "123456789", + "--target-type", "objective", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + records, _ := data["progress_list"].([]interface{}) + if len(records) != 1 { + t.Fatalf("expected 1 progress, got %d", len(records)) + } +} + +func TestProgressListExecute_Success_KeyResult(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressListTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v2/key_results/987654321/progresses", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "id": "222", + "create_time": "1735776000000", + "update_time": "1735776100000", + "owner": map[string]interface{}{"owner_type": "user", "user_id": "ou_test"}, + "entity_type": 3, + "entity_id": "987654321", + "progress_rate": map[string]interface{}{ + "progress_percent": 100.0, + "progress_status": 2, + }, + }, + }, + "has_more": false, + }, + }, + }) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-id", "987654321", + "--target-type", "key_result", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + records, _ := data["progress_list"].([]interface{}) + if len(records) != 1 { + t.Fatalf("expected 1 progress, got %d", len(records)) + } + record := records[0].(map[string]interface{}) + pr := record["progress_rate"].(map[string]interface{}) + if pr["status"] != "done" { + t.Fatalf("progress status = %v, want done", pr["status"]) + } +} + +func TestProgressListExecute_EmptyList(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressListTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v2/objectives/123456789/progresses", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "items": []interface{}{}, + "has_more": false, + }, + }, + }) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-id", "123456789", + "--target-type", "objective", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + records, _ := data["progress_records"].([]interface{}) + if len(records) != 0 { + t.Fatalf("expected 0 progress, got %d", len(records)) + } +} + +func TestProgressListExecute_APIError(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressListTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v2/objectives/999/progresses", + Status: 500, + Body: map[string]interface{}{ + "code": 999, + "msg": "internal error", + }, + }) + err := runProgressListShortcut(t, f, stdout, []string{ + "+progress-list", + "--target-id", "999", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for API failure") + } +} diff --git a/shortcuts/okr/okr_progress_update.go b/shortcuts/okr/okr_progress_update.go new file mode 100644 index 000000000..6a2cfedb2 --- /dev/null +++ b/shortcuts/okr/okr_progress_update.go @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "strconv" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +// updateProgressRecordParams holds the parsed parameters for updating a progress. +type updateProgressRecordParams struct { + ProgressID string + ContentV1 *ContentBlockV1 + ProgressRate *ProgressRateV1 + UserIDType string +} + +// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters. +func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) { + content := runtime.Str("content") + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return nil, common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err) + } + contentV1 := cb.ToV1() + + var progressRate *ProgressRateV1 + if v := runtime.Str("progress-percent"); v != "" { + percent, err := strconv.ParseFloat(v, 64) + if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 { + return nil, common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999") + } + progressRate = &ProgressRateV1{Percent: &percent} + if s := runtime.Str("progress-status"); s != "" { + status, ok := ParseProgressStatus(s) + if !ok { + return nil, common.FlagErrorf("--progress-status must be one of: normal | overdue | done") + } + progressRate.Status = int32Ptr(int32(status)) + } + } + + return &updateProgressRecordParams{ + ProgressID: runtime.Str("progress-id"), + ContentV1: contentV1, + ProgressRate: progressRate, + UserIDType: runtime.Str("user-id-type"), + }, nil +} + +// OKRUpdateProgressRecord updates a progress. +var OKRUpdateProgressRecord = common.Shortcut{ + Service: "okr", + Command: "+progress-update", + Description: "Update an OKR progress", + Risk: "write", + Scopes: []string{"okr:okr.progress:writeonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "progress-id", Desc: "progress ID (int64)", Required: true}, + {Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "progress-percent", Desc: "progress percentage"}, + {Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}}, + {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + progressID := runtime.Str("progress-id") + if progressID == "" { + return common.FlagErrorf("--progress-id is required") + } + if id, err := strconv.ParseInt(progressID, 10, 64); err != nil || id <= 0 { + return common.FlagErrorf("--progress-id must be a positive int64") + } + + content := runtime.Str("content") + if content == "" { + return common.FlagErrorf("--content is required") + } + if err := validate.RejectControlChars(content, "content"); err != nil { + return err + } + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return common.FlagErrorf("--content must be valid ContentBlock JSON: %s", err) + } + + if v := runtime.Str("progress-percent"); v != "" { + percent, err := strconv.ParseFloat(v, 64) + if err != nil || math.IsNaN(percent) || math.IsInf(percent, 0) || percent < -99999999999 || percent > 99999999999 { + return common.FlagErrorf("--progress-percent must be a number between -99999999999 and 99999999999") + } + } + if v := runtime.Str("progress-status"); v != "" { + if _, ok := ParseProgressStatus(v); !ok { + return common.FlagErrorf("--progress-status must be one of: normal | overdue | done") + } + if v := runtime.Str("progress-percent"); v == "" { + return common.FlagErrorf("--progress-percent must provided with --progress-status") + } + } + + idType := runtime.Str("user-id-type") + if idType != "open_id" && idType != "union_id" && idType != "user_id" { + return common.FlagErrorf("--user-id-type must be one of: open_id | union_id | user_id") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + p, _ := parseUpdateProgressRecordParams(runtime) + params := map[string]interface{}{ + "user_id_type": p.UserIDType, + } + body := map[string]interface{}{ + "content": p.ContentV1, + } + if p.ProgressRate != nil { + body["progress_rate"] = p.ProgressRate + } + return common.NewDryRunAPI(). + PUT("/open-apis/okr/v1/progress_records/:progress_id"). + Params(params). + Body(body). + Set("progress_id", p.ProgressID). + Desc("Update OKR progress") + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + p, err := parseUpdateProgressRecordParams(runtime) + if err != nil { + return err + } + + body := map[string]interface{}{ + "content": p.ContentV1, + } + if p.ProgressRate != nil { + body["progress_rate"] = p.ProgressRate + } + + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", p.UserIDType) + + path := fmt.Sprintf("/open-apis/okr/v1/progress_records/%s", p.ProgressID) + data, err := runtime.DoAPIJSON("PUT", path, queryParams, body) + if err != nil { + return err + } + + record, err := parseProgressRecord(data) + if err != nil { + return err + } + + resp := record.ToResp() + result := map[string]interface{}{ + "progress": resp, + } + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", *resp.Content) + } + }) + return nil + }, +} diff --git a/shortcuts/okr/okr_progress_update_test.go b/shortcuts/okr/okr_progress_update_test.go new file mode 100644 index 000000000..6d56d4dd3 --- /dev/null +++ b/shortcuts/okr/okr_progress_update_test.go @@ -0,0 +1,272 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +func progressUpdateTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + return &core.CliConfig{ + AppID: "test-okr-progress-update", + AppSecret: "secret-okr-progress-update", + Brand: core.BrandFeishu, + } +} + +func runProgressUpdateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + parent := &cobra.Command{Use: "okr"} + OKRUpdateProgressRecord.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// --- Validate tests --- + +func TestProgressUpdateValidate_MissingProgressID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--content", validContentBlockJSON, + }) + if err == nil { + t.Fatal("expected error for missing --progress-id") + } +} + +func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "abc", + "--content", validContentBlockJSON, + }) + if err == nil { + t.Fatal("expected error for invalid --progress-id") + } + if !strings.Contains(err.Error(), "--progress-id must be a positive int64") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressUpdateValidate_MissingContent(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + }) + if err == nil { + t.Fatal("expected error for missing --content") + } +} + +func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + "--content", "not-json", + }) + if err == nil { + t.Fatal("expected error for invalid --content JSON") + } + if !strings.Contains(err.Error(), "--content must be valid ContentBlock JSON") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + "--content", validContentBlockJSON, + "--user-id-type", "invalid", + }) + if err == nil { + t.Fatal("expected error for invalid --user-id-type") + } +} + +func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + "--content", validContentBlockJSON, + "--progress-percent", "-999999999999", + }) + if err == nil { + t.Fatal("expected error for negative --progress-percent") + } + if !strings.Contains(err.Error(), "--progress-percent must be a number between -99999999999 and 99999999999") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + "--content", validContentBlockJSON, + "--progress-status", "invalid_status", + }) + if err == nil { + t.Fatal("expected error for invalid --progress-status") + } + if !strings.Contains(err.Error(), "--progress-status") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressUpdateValidate_Valid(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/okr/v1/progress_records/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "123", + "modify_time": "1735776000000", + }, + }, + }) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + "--content", validContentBlockJSON, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- DryRun tests --- + +func TestProgressUpdateDryRun(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "456", + "--content", validContentBlockJSON, + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "456") { + t.Fatalf("dry-run output should contain progress-id 456, got: %s", output) + } + if !strings.Contains(output, "/open-apis/okr/v1/progress_records/456") { + t.Fatalf("dry-run output should contain API path, got: %s", output) + } + if !strings.Contains(output, "PUT") { + t.Fatalf("dry-run output should contain PUT method, got: %s", output) + } +} + +func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "456", + "--content", validContentBlockJSON, + "--progress-percent", "50", + "--progress-status", "overdue", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "progress_rate") { + t.Fatalf("dry-run output should contain progress_rate, got: %s", output) + } +} + +// --- Execute tests --- + +func TestProgressUpdateExecute_Success(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/okr/v1/progress_records/789", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "789", + "modify_time": "1735776000000", + }, + }, + }) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "789", + "--content", validContentBlockJSON, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + pr, _ := data["progress"].(map[string]interface{}) + if pr == nil { + t.Fatal("expected progress in output") + } + if pr["progress_id"] != "789" { + t.Fatalf("progress_id = %v, want 789", pr["progress_id"]) + } +} + +func TestProgressUpdateExecute_APIError(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/okr/v1/progress_records/999", + Status: 500, + Body: map[string]interface{}{ + "code": 999, + "msg": "internal error", + }, + }) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "999", + "--content", validContentBlockJSON, + }) + if err == nil { + t.Fatal("expected error for API failure") + } +} diff --git a/shortcuts/okr/shortcuts.go b/shortcuts/okr/shortcuts.go index afdcd8412..8ee5f30f9 100644 --- a/shortcuts/okr/shortcuts.go +++ b/shortcuts/okr/shortcuts.go @@ -12,5 +12,11 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ OKRListCycles, OKRCycleDetail, + OKRListProgress, + OKRGetProgressRecord, + OKRCreateProgressRecord, + OKRUpdateProgressRecord, + OKRDeleteProgressRecord, + OKRUploadImage, } } diff --git a/skills/lark-okr/SKILL.md b/skills/lark-okr/SKILL.md index 0c850b50d..37156009f 100644 --- a/skills/lark-okr/SKILL.md +++ b/skills/lark-okr/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-okr version: 1.0.0 -description: "飞书 OKR:管理目标与关键结果。查看和编辑 OKR 周期、目标(Objective)、关键结果(Key Result)、对齐关系、量化指标。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。" +description: "飞书 OKR:管理目标与关键结果。查看和编辑 OKR 周期、目标(Objective)、关键结果(Key Result)、对齐关系、量化指标和进展记录。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。" metadata: requires: bins: [ "lark-cli" ] @@ -16,15 +16,21 @@ metadata: Shortcut 是对常用操作的高级封装(`lark-cli okr + [flags]`)。有 Shortcut 的操作优先使用。 -| Shortcut | 说明 | -|--------------------------------------------------------|--------------------------| -| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 | -| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 | +| Shortcut | 说明 | +|--------------------------------------------------------------|--------------------------| +| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 | +| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 | +| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 | +| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 | +| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 | +| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 | +| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) | +| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 | ## 格式说明 -- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Notes 字段使用的富文本格式说明 - [`OKR 业务实体`](references/lark-okr-entities.md) 获取 OKR 实体结构,定义和关系,帮助你更好的使用 OKR 功能 +- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note 字段使用的富文本格式说明 - **强烈建议** 在操作 OKR 前,阅读[`OKR 业务实体`](references/lark-okr-entities.md)以了解基础概念 ## API Resources @@ -49,9 +55,9 @@ lark-cli okr [flags] # 调用 API - `list` — 批量获取用户周期 - `objectives_position` — 更新用户周期下全部目标的位置 - - 请求中必须同时修改对应周期下全部目标的位置,且不允许位置重叠,否则会参数校验失败。 + - 请求中必须同时修改对应周期下全部目标的位置,且不允许位置重叠,否则会参数校验失败。 - `objectives_weight` — 更新用户周期下全部目标的权重 - - 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。 + - 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。 ### cycle.objectives @@ -77,15 +83,15 @@ lark-cli okr [flags] # 调用 API - `delete` — 删除目标 - `get` — 获取目标 - `key_results_position` — 更新全部关键结果的位置 - - 请求中必须同时修改对应目标下全部关键结果的位置,且不允许位置重叠,否则会参数校验失败。 + - 请求中必须同时修改对应目标下全部关键结果的位置,且不允许位置重叠,否则会参数校验失败。 - `key_results_weight` — 更新全部关键结果的权重 - - 请求中必须同时修改对应目标下全部关键结果的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。 + - 请求中必须同时修改对应目标下全部关键结果的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。 - `patch` — 更新目标 ### objective.alignments - `create` — 创建对齐关系 - - 对齐不允许对齐自己的目标,且发起对齐的目标和被对齐的目标所在周期时间上必须有重叠,否则会参数校验失败。 + - 对齐不允许对齐自己的目标,且发起对齐的目标和被对齐的目标所在周期时间上必须有重叠,否则会参数校验失败。 - `list` — 批量获取目标下的对齐关系 ### objective.indicators diff --git a/skills/lark-okr/references/lark-okr-contentblock.md b/skills/lark-okr/references/lark-okr-contentblock.md index dbbd3224c..24295704a 100644 --- a/skills/lark-okr/references/lark-okr-contentblock.md +++ b/skills/lark-okr/references/lark-okr-contentblock.md @@ -168,7 +168,7 @@ OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock` ### ContentGallery -图库。 +图片块,允许展示多个图片。目前仅有进展记录中的富文本支持展示图片。 | 字段 | 类型 | 说明 | |----------|----------------------|-------| @@ -185,6 +185,8 @@ OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock` | `width` | `float64` | 宽度 | | `height` | `float64` | 高度 | +> **如何获取 `file_token`?** 使用 [`+upload-image`](lark-okr-image-upload.md) 命令上传本地图片,返回的 `file_token` 可用于构建 `ContentGallery` 图片块。 + ### ContentDocsLink 飞书文档链接。 diff --git a/skills/lark-okr/references/lark-okr-cycle-detail.md b/skills/lark-okr/references/lark-okr-cycle-detail.md index 064d35e0d..532bbe5cf 100644 --- a/skills/lark-okr/references/lark-okr-cycle-detail.md +++ b/skills/lark-okr/references/lark-okr-cycle-detail.md @@ -16,11 +16,11 @@ lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run ## 参数 -| 参数 | 必填 | 默认值 | 说明 | -|-------------------------|----|--------|-----------------------------------------| -| `--cycle-id <id>` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 | -| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | -| `--format ` | 否 | `json` | 输出格式。 | +| 参数 | 必填 | 默认值 | 说明 | +|--------------|----|--------|-----------------------------------------| +| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | ## 工作流程 @@ -74,8 +74,9 @@ lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run "total": 1 } ``` -其中,content 和 notes 字段是 JSON 字符串,为 OKR ContentBlock 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。 +其中,content 和 notes 字段是 JSON 字符串,为 OKR ContentBlock +富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。 ## 参考 diff --git a/skills/lark-okr/references/lark-okr-cycle-list.md b/skills/lark-okr/references/lark-okr-cycle-list.md index a8bce8dc4..ac7a1223a 100644 --- a/skills/lark-okr/references/lark-okr-cycle-list.md +++ b/skills/lark-okr/references/lark-okr-cycle-list.md @@ -22,13 +22,13 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run ## 参数 -| 参数 | 必填 | 默认值 | 说明 | -|-------------------------------|----|-----------|------------------------------------------------------------------| -| `--user-id <id>` | 是 | — | OKR 所有者的用户 ID | -| `--user-id-type <type>` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | -| `--time-range <range>` | 否 | — | 按时间范围过滤周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。留空获取所有周期。 | -| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | -| `--format <fmt>` | 否 | `json` | 输出格式。 | +| 参数 | 必填 | 默认值 | 说明 | +|------------------|----|-----------|------------------------------------------------------------------| +| `--user-id` | 是 | — | OKR 所有者的用户 ID | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--time-range` | 否 | — | 按时间范围过滤周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。留空获取所有周期。 | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | ## 工作流程 @@ -64,10 +64,13 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run ``` 在这个周期信息中,这些字段值得关注: + - `id` 是这个周期的 ID,你通常需要用它在之后使用 `okr +cycle-detail` 获取 OKR 内容详情 - `start_time` `end_time` 是周期的起止时间,总是从某个月1日开始,直到此月或之后某月的最后一日结束。 - - 在 OKR 系统中,我们只关注这个时间的年月部分,如 “2025-01-01开始,2025-06-30结束” 的周期被称作 “2025 年 1-6 月” 周期,而 “2025-01-01开始,2025-01-31结束” 的周期被称作 “2025 年 1 月”周期。 - - 如果一个周期从某年1月1日开始,某年12月31日结束,则它是这一年的年度周期,如 “2025-01-01开始,2025-12-31结束” 的周期就是 “2025 年” 的年度周期 + - 在 OKR 系统中,我们只关注这个时间的年月部分,如 “2025-01-01开始,2025-06-30结束” 的周期被称作 “2025 年 1-6 月” 周期,而 + “2025-01-01开始,2025-01-31结束” 的周期被称作 “2025 年 1 月”周期。 + - 如果一个周期从某年1月1日开始,某年12月31日结束,则它是这一年的年度周期,如 “2025-01-01开始,2025-12-31结束” 的周期就是 + “2025 年” 的年度周期 - `cycle_status` 为周期状态值,参见下文。 ### 周期状态值 diff --git a/skills/lark-okr/references/lark-okr-entities.md b/skills/lark-okr/references/lark-okr-entities.md index 2b73038aa..52bd840ea 100644 --- a/skills/lark-okr/references/lark-okr-entities.md +++ b/skills/lark-okr/references/lark-okr-entities.md @@ -9,7 +9,9 @@ Cycle (用户周期) └── Objective (目标) ├── KeyResult (关键结果) │ └── Indicator (指标) + │ └── list (进展记录列表) └── Indicator (指标) + └── list (进展记录列表) Alignment (对齐关系): Objective ↔ Objective Category (分类): Objective 的分组标签 @@ -46,7 +48,8 @@ Category (分类): Objective 的分组标签 ### 常用术语 -- **当前周期**: 指周期的 start_time/end_time 所在的时间段与当前时间重叠的周期。如果有多个符合这一标准的周期,通常可以选择周期状态为default/normal的周期,其中较新的一个。当用户提及“上一个周期”,“下一个周期”一类的表述时,通常是以当前周期为准计算。 +- **当前周期**: 指周期的 start_time/end_time + 所在的时间段与当前时间重叠的周期。如果有多个符合这一标准的周期,通常可以选择周期状态为default/normal的周期,其中较新的一个。当用户提及“上一个周期”,“下一个周期”一类的表述时,通常是以当前周期为准计算。 - **所有者**: 绝大多数所有者都是用户,少部分租户启用了“团队OKR”功能,所有者可能是部门。用户身份下,只能编辑所有者为当前用户的 OKR。 @@ -124,6 +127,53 @@ Category (分类): Objective 的分组标签 --- +## Progress (进展记录) + +进展记录挂载在目标(Objective)或关键结果(Key Result)上,用于记录阶段性进展内容与进度百分比。每条进展记录包含富文本内容和可选的进度率。 + +| 字段 | 类型 | 必填 | 说明 | +|-----------------|----------------|----|---------------------------------------------------------| +| `progress_id` | `string` | 是 | 进展记录 ID(int64,正整数) | +| `modify_time` | `string` | 是 | 最后修改时间,毫秒时间戳,shortcut 会将其解析为日期时间 | +| `content` | `ContentBlock` | 否 | 进展内容(富文本),见 [ContentBlock 定义](lark-okr-contentblock.md) | +| `progress_rate` | `ProgressRate` | 否 | 进度率,包含百分比和状态 | + +### ProgressRate (进度率) + +| 字段 | 类型 | 必填 | 说明 | +|-----------|----------|----|------------------------------------------------------------------------------------------------------------------------------| +| `percent` | `number` | 否 | 进度百分比,范围 [-99999999999, 99999999999]。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 | +| `status` | `string` | 否 | 进度状态,shortcut 返回可读字符串,见下表 | + +### 进度状态 (progress_rate.status) + +| 值 | 常量名 | 说明 | +|-----------|-----|-------| +| `normal` | 正常 | 进展正常 | +| `overdue` | 逾期 | 进展逾期 | +| `done` | 已完成 | 进展已完成 | + +### 创建进展记录时的参数 + +创建进展记录时,除了 `content` 外,还需要指定这条进展记录挂载的对应目标或关键结果: + +| 字段 | 类型 | 必填 | 说明 | +|-----------------|----------------|----|---------------------------------------------------------| +| `content` | `ContentBlock` | 是 | 进展内容(富文本),见 [ContentBlock 定义](lark-okr-contentblock.md) | +| `target_id` | `string` | 是 | 目标 ID 或关键结果 ID | +| `target_type` | `integer` | 是 | 目标类型:`2`=目标(Objective),`3`=关键结果(KeyResult) | +| `progress_rate` | `ProgressRate` | 否 | 进度率,可设置 `percent` 和 `status` | +| `source_title` | `string` | 否 | 来源标题,用于在 OKR 界面中显示进展来源 | +| `source_url` | `string` | 否 | 来源 URL,用于在 OKR 界面中显示进展来源链接 | + +> **SHORTCUT:** +> - `okr +progress-get` [lark-okr-progress-get.md](lark-okr-progress-get.md) 获取单条进展记录 +> - `okr +progress-create` [lark-okr-progress-create.md](lark-okr-progress-create.md) 为目标或关键结果创建进展记录 +> - `okr +progress-update` [lark-okr-progress-update.md](lark-okr-progress-update.md) 更新进展记录内容 +> - `okr +progress-delete` [lark-okr-progress-delete.md](lark-okr-progress-delete.md) 删除进展记录 +> - `okr +progress-list` [lark-okr-progress-list.md](lark-okr-progress-list.md) 获取目标/关键结果下的进展记录 +--- + ## Indicator (指标) 指标是目标和关键结果的量化度量,可独立挂载在 Objective 或 KeyResult 上。 @@ -148,7 +198,8 @@ Category (分类): Objective 的分组标签 - **进度值**: 一般指 `current_value`,单位未提及时通常用百分制计算。 - 当用户要求量化的更新 OKR 进度时,一般指的就是修改对应 OKR 的 Indicator。 -- OKR 在未设置量化指标时,Indicator 的内容为空。如果用户未做特别说明,更新进度时可以默认将进度以百分制设置(初始值0,目标值100,unit 参见下文设置为 0/PERCENT) +- OKR 在未设置量化指标时,Indicator 的内容为空。如果用户未做特别说明,更新进度时可以默认将进度以百分制设置(初始值0,目标值100,unit + 参见下文设置为 0/PERCENT) ### 指标状态 (indicator_status) @@ -254,12 +305,16 @@ Category (分类): Objective 的分组标签 ## 权限 Scope 说明 -| Scope | 权限类型 | 说明 | -|-----------------------------|------|--------------| -| `okr:okr.content:readonly` | 读 | 读取 OKR 内容 | -| `okr:okr.content:writeonly` | 写 | 写入/删除 OKR 内容 | -| `okr:okr.period:readonly` | 读 | 读取 OKR 周期 | -| `okr:okr.setting:read` | 读 | 读取 OKR 设置 | +| Scope | 权限类型 | 说明 | +|--------------------------------|------|--------------| +| `okr:okr.content:readonly` | 读 | 读取 OKR 内容 | +| `okr:okr.content:writeonly` | 写 | 写入/删除 OKR 内容 | +| `okr:okr.period:readonly` | 读 | 读取 OKR 周期 | +| `okr:okr.progress:readonly` | 读 | 读取进展记录 | +| `okr:okr.progress:writeonly` | 写 | 创建/更新进展记录 | +| `okr:okr.progress:delete` | 写 | 删除进展记录 | +| `okr:okr.progress.file:upload` | 写 | 上传进展记录图片附件 | +| `okr:okr.setting:read` | 读 | 读取 OKR 设置 | 所有 OKR API 均支持 `user` 和 `tenant`(应用)两种 access token 类型。 @@ -268,3 +323,7 @@ Category (分类): Objective 的分组标签 - [OKR ContentBlock 富文本格式](lark-okr-contentblock.md) — content/notes 字段的富文本结构定义 - [okr +cycle-list](lark-okr-cycle-list.md) — 列出用户 OKR 周期 - [okr +cycle-detail](lark-okr-cycle-detail.md) — 获取周期下的目标与关键结果 +- [okr +progress-get](lark-okr-progress-get.md) — 获取进展记录 +- [okr +progress-create](lark-okr-progress-create.md) — 创建进展记录 +- [okr +progress-update](lark-okr-progress-update.md) — 更新进展记录 +- [okr +progress-delete](lark-okr-progress-delete.md) — 删除进展记录 diff --git a/skills/lark-okr/references/lark-okr-image-upload.md b/skills/lark-okr/references/lark-okr-image-upload.md new file mode 100644 index 000000000..85fd8cc36 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-image-upload.md @@ -0,0 +1,116 @@ +# okr +upload-image + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +上传本地图片,用于 OKR 进展记录的富文本内容。 + +## 推荐命令 + +```bash +# 上传图片用于目标的进展记录 +lark-cli okr +upload-image \ + --file ./progress_screenshot.png \ + --target-id 1234567890123456789 \ + --target-type objective + +# 上传图片用于关键结果的进展记录 +lark-cli okr +upload-image \ + --file ./chart.jpg \ + --target-id 9876543210987654321 \ + --target-type key_result +``` + +## 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|-----------------|----|-----|---------------------------------------| +| `--file` | 是 | — | 本地图片路径。**必须使用相对路径**(如 `./photo.png`)。 | +| `--target-id` | 是 | — | 目标 ID 或关键结果 ID(int64 类型,正整数) | +| `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | + +## 工作流程 + +1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。 +2. 准备本地图片文件,确保格式受支持。 +3. 执行 `lark-cli okr +upload-image --file ./image.png --target-id "..." --target-type objective`。 +4. 获取返回的 `file_token`,用于构建 ContentBlock 中的图片内容。 + +## 输出 + +返回 JSON: + +```json +{ + "file_token": "example-file-token", + "url": "https://example.larksuite.com/download?file_token=example-file-token", + "file_name": "screenshot.png", + "size": 102400 +} +``` + +其中: + +- `file_token` — 用于在 ContentBlock 的 `ContentGallery` 中引用图片 +- `url` — 图片的访问 URL +- `file_name` — 上传的文件名 +- `size` — 文件大小(字节) + +## 在进展记录中使用上传的图片 + +上传图片后,将返回的 `file_token` 用于构建 ContentBlock 的图库块: + +```json +{ + "blocks": [ + { + "block_element_type": "paragraph", + "paragraph": { + "elements": [ + { + "paragraph_element_type": "textRun", + "text_run": { + "text": "本周进展截图:" + } + } + ] + } + }, + { + "block_element_type": "gallery", + "gallery": { + "images": [ + { + "file_token": "example-file-token", + "width": 800, + "height": 600 + } + ] + } + } + ] +} +``` + +然后在创建或更新进展记录时使用此 ContentBlock: + +```bash +lark-cli okr +progress-create \ + --content @content_with_image.json \ + --target-id 1234567890123456789 \ + --target-type objective +``` + +## 安全限制 + +- `--file` 参数**必须使用相对路径**(如 `./photo.png` 或 `images/photo.png`),不支持绝对路径 +- 图片文件必须存在于当前工作目录或其子目录中 +- 不支持符号链接指向目录外的文件 + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口) +- [ContentBlock 格式](lark-okr-contentblock.md) -- 进展内容使用的富文本格式,包含图片块的使用说明 +- [lark-okr-progress-create](lark-okr-progress-create.md) -- 创建进展记录 +- [lark-okr-progress-update](lark-okr-progress-update.md) -- 更新进展记录 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-okr/references/lark-okr-progress-create.md b/skills/lark-okr/references/lark-okr-progress-create.md new file mode 100644 index 000000000..3731f922f --- /dev/null +++ b/skills/lark-okr/references/lark-okr-progress-create.md @@ -0,0 +1,81 @@ +# okr +progress-create + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +为目标(Objective)或关键结果(Key Result)创建一条 OKR 进展记录。 + +## 推荐命令 + +```bash +# 为目标创建进展记录 +lark-cli okr +progress-create \ + --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"本周完成了核心模块开发"}}]}}]}' \ + --target-id 1234567890123456789 \ + --target-type objective + +# 为关键结果创建进展记录(带进度百分比和状态) +lark-cli okr +progress-create \ + --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"指标已达到 80%"}}]}}]}' \ + --target-id 2345678901234567891 \ + --target-type key_result \ + --progress-percent 80 \ + --progress-status done + +# 从文件读取 content(适用于较长的进展内容) +lark-cli okr +progress-create \ + --content @progress_content.json \ + --target-id 1234567890123456789 \ + --target-type objective +``` + +## 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|----------------------|----|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------| +| `--content` | 是 | — | 进展内容,ContentBlock JSON 格式。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--target-id` | 是 | — | 目标 ID 或关键结果 ID(int64 类型,正整数) | +| `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` | +| `--progress-percent` | 否 | — | 进度百分比(-99999999999 - 99999999999)。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 | +| `--progress-status` | 否 | — | 进度状态:`normal`(正常) \| `overdue`(逾期) \| `done`(已完成)。仅在指定 `--progress-percent` 时生效。 | +| `--source-title` | 否 | `created by lark-cli` | 来源标题,用于在 OKR 界面中显示进展来源 | +| `--source-url` | 否 | 根据品牌自动生成 | 来源 URL,用于在 OKR 界面中显示进展来源链接,通常可以填写 OKR 编写信息来源的文档链接等。飞书品牌默认为 `https://open.feishu.cn/app`, Lark 品牌默认为 `https://open.larksuite.com/app` | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | + +## 工作流程 + +1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。 +2. 构造 ContentBlock JSON 格式的进展内容。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 +3. 执行 `lark-cli okr +progress-create --content "..." --target-id "..." --target-type objective`。 +4. 报告结果:新创建的进展记录 ID、修改时间等。 + +## 输出 + +返回 JSON: + +```json +{ + "progress": { + "progress_id": "1234567890123456789", + "modify_time": "2025-01-15 10:30:00", + "content": "{...}", + "progress_rate": { + "percent": 80.0, + "status": "done" + } + } +} +``` + +其中: + +- `content` 字段是 JSON 字符串,为 OKR ContentBlock + 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。 +- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。 + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口) +- [ContentBlock 格式](lark-okr-contentblock.md) -- 进展内容使用的富文本格式 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-okr/references/lark-okr-progress-delete.md b/skills/lark-okr/references/lark-okr-progress-delete.md new file mode 100644 index 000000000..7376434b7 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-progress-delete.md @@ -0,0 +1,47 @@ +# okr +progress-delete + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +根据 ID 删除一条 OKR 进展记录。此操作为高风险操作,删除后不可恢复。 + +## 推荐命令 + +```bash +# 删除指定 ID 的进展记录 +lark-cli okr +progress-delete --progress-id 1234567890123456789 + +# 预览 API 调用而不实际执行 +lark-cli okr +progress-delete --progress-id 1234567890123456789 --dry-run +``` + +## 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|-----------------|----|--------|-----------------------| +| `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | + +## 工作流程 + +1. 使用 `+progress-get` 确认要删除的进展记录 ID 和内容。 +2. 执行 `lark-cli okr +progress-delete --progress-id "1234567890123456789"`。 +3. 报告结果:已删除的进展记录 ID。 + +> **注意**:此操作不可恢复,建议在删除前先用 `+progress-get` 确认记录内容。 + +## 输出 + +返回 JSON: + +```json +{ + "deleted": true, + "progress_id": "1234567890123456789" +} +``` + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口) +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-okr/references/lark-okr-progress-get.md b/skills/lark-okr/references/lark-okr-progress-get.md new file mode 100644 index 000000000..ddc5e29e5 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-progress-get.md @@ -0,0 +1,62 @@ +# okr +progress-get + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +根据进展记录 ID 获取单条 OKR 进展记录。 + +## 推荐命令 + +```bash +# 获取指定 ID 的进展记录 +lark-cli okr +progress-get --progress-id 1234567890123456789 + +# 使用特定的用户 ID 类型 +lark-cli okr +progress-get --progress-id 1234567890123456789 --user-id-type open_id + +# 预览 API 调用而不实际执行 +lark-cli okr +progress-get --progress-id 1234567890123456789 --dry-run +``` + +## 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|------------------|----|-----------|-----------------------------------------------| +| `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | + +## 工作流程 + +1. 获取目标进展记录的 ID。可通过 `+cycle-detail` 获取目标和关键结果后,从中获取进展记录 ID。 +2. 执行 `lark-cli okr +progress-get --progress-id "1234567890123456789"`。 +3. 报告结果:进展记录的 ID、修改时间、进度百分比和内容。 + +## 输出 + +返回 JSON: + +```json +{ + "progress": { + "progress_id": "1234567890123456789", + "modify_time": "2025-01-15 10:30:00", + "content": "{...}", + "progress_rate": { + "percent": 75.0, + "status": "normal" + } + } +} +``` + +其中: + +- `content` 字段是 JSON 字符串,为 OKR ContentBlock + 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。 +- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。 + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口) +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-okr/references/lark-okr-progress-list.md b/skills/lark-okr/references/lark-okr-progress-list.md new file mode 100644 index 000000000..7d495f744 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-progress-list.md @@ -0,0 +1,80 @@ +# okr +progress-list + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +获取目标(Objective)或关键结果(Key Result)的所有进展记录列表。 + +## 推荐命令 + +```bash +# 获取目标的所有进展记录 +lark-cli okr +progress-list \ + --target-id 1234567890123456789 \ + --target-type objective + +# 获取关键结果的所有进展记录 +lark-cli okr +progress-list \ + --target-id 9876543210987654321 \ + --target-type key_result +``` + +## 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|-------------------------|----|--------------------|--------------------------------------------------| +| `--target-id` | 是 | — | 目标 ID 或关键结果 ID(int64 类型,正整数) | +| `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--department-id-type` | 否 | `open_department_id` | 部门 ID 类型:`department_id` \| `open_department_id` | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | + +## 工作流程 + +1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。 +2. 执行 `lark-cli okr +progress-list --target-id "..." --target-type objective`。 +3. 获取该目标或关键结果下的所有进展记录列表。 + +## 输出 + +返回 JSON: + +```json +{ + "progress": [ + { + "progress_id": "1234567890123456789", + "modify_time": "2025-01-15 10:30:00", + "content": "{...}", + "progress_rate": { + "percent": 80.0, + "status": "done" + } + } + ], + "total": 1 +} +``` + +其中: + +- `progress` — 进展记录数组 +- `content` 字段是 JSON 字符串,为 OKR ContentBlock 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。 +- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。 + +## 与 +progress-get 的区别 + +| 命令 | 用途 | API 版本 | +|------------------|------------------------------------|----------| +| `+progress-list` | 获取某个目标/关键结果的所有进展记录 | v2 | +| `+progress-get` | 根据进展记录 ID 获取单条记录 | v1 | + +`+progress-list` 返回的 `progress_records` 数组中每条记录的结构与 `+progress-get` 返回的 `progress_record` 结构相同。 + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口) +- [ContentBlock 格式](lark-okr-contentblock.md) -- 进展内容使用的富文本格式 +- [lark-okr-progress-get](lark-okr-progress-get.md) -- 根据 ID 获取单条进展记录 +- [lark-okr-progress-create](lark-okr-progress-create.md) -- 创建进展记录 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-okr/references/lark-okr-progress-update.md b/skills/lark-okr/references/lark-okr-progress-update.md new file mode 100644 index 000000000..a047da847 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-progress-update.md @@ -0,0 +1,81 @@ +# okr +progress-update + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +更新指定 ID 的 OKR 进展记录内容。 + +## 推荐命令 + +```bash +# 更新进展记录内容 +lark-cli okr +progress-update \ + --progress-id 1234567890123456789 \ + --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"更新后的进展内容"}}]}}]}' + +# 更新进展记录内容并同时更新进度 +lark-cli okr +progress-update \ + --progress-id 1234567890123456789 \ + --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"进度已更新至 90%"}}]}}]}' \ + --progress-percent 90 \ + --progress-status normal + +# 从文件读取 content(适用于较长的进展内容) +lark-cli okr +progress-update \ + --progress-id 1234567890123456789 \ + --content @updated_progress.json + +# 预览 API 调用而不实际执行 +lark-cli okr +progress-update \ + --progress-id 1234567890123456789 \ + --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test"}}]}}]}' \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|----------------------|----|-----------|----------------------------------------------------------------------------------------------------------------| +| `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) | +| `--content` | 是 | — | 进展内容,ContentBlock JSON 格式。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--progress-percent` | 否 | — | 进度百分比(-99999999999 - 99999999999)。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 | +| `--progress-status` | 否 | — | 进度状态:`normal`(正常) \| `overdue`(逾期) \| `done`(已完成)。仅在指定 `--progress-percent` 时生效。 | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | + +## 工作流程 + +1. 使用 `+progress-get` 获取要更新的进展记录的 ID 和当前内容。 +2. 修改 ContentBlock JSON 格式的进展内容。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 +3. 执行 `lark-cli okr +progress-update --progress-id "..." --content "..."`。 +4. 报告结果:更新后的进展记录 ID、修改时间、进度百分比等。 + +## 输出 + +返回 JSON: + +```json +{ + "progress": { + "progress_id": "1234567890123456789", + "modify_time": "2025-01-15 14:30:00", + "content": "{...}", + "progress_rate": { + "percent": 90.0, + "status": "normal" + } + } +} +``` + +其中: + +- `content` 字段是 JSON 字符串,为 OKR ContentBlock + 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。 +- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。 + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口) +- [ContentBlock 格式](lark-okr-contentblock.md) -- 进展内容使用的富文本格式 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/tests/cli_e2e/okr/okr_progress_test.go b/tests/cli_e2e/okr/okr_progress_test.go new file mode 100644 index 000000000..d3b0816c9 --- /dev/null +++ b/tests/cli_e2e/okr/okr_progress_test.go @@ -0,0 +1,273 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Upload Image Dry-run E2E tests --- + +// TestOKR_UploadImageDryRun validates +upload-image dry-run output contains the correct method and API path. +func TestOKR_UploadImageDryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+upload-image", + "--file", "./test.png", + "--target-id", "123456789", + "--target-type", "objective", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v1/images/upload"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, "POST"), "dry-run should contain POST method, got: %s", output) +} + +// TestOKR_UploadImageDryRun_KeyResult validates +upload-image dry-run with key_result target type. +func TestOKR_UploadImageDryRun_KeyResult(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+upload-image", + "--file", "./test.jpg", + "--target-id", "987654321", + "--target-type", "key_result", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v1/images/upload"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, "key_result"), "dry-run should contain target type, got: %s", output) +} + +// --- Progress Create Dry-run E2E tests --- + +// TestOKR_ProgressCreateDryRun validates +progress-create dry-run output contains the correct method and API path. +func TestOKR_ProgressCreateDryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+progress-create", + "--content", `{"blocks":[{"type":"text","text":"test progress"}]}`, + "--target-id", "123456789", + "--target-type", "objective", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v1/progress_records/"), "dry-run should contain API path, got: %s", output) + assert.True(t, strings.Contains(output, "POST"), "dry-run should contain POST method, got: %s", output) + assert.True(t, strings.Contains(output, "123456789"), "dry-run should contain target-id, got: %s", output) +} + +// TestOKR_ProgressCreateDryRun_WithProgress validates +progress-create dry-run with progress rate. +func TestOKR_ProgressCreateDryRun_WithProgress(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+progress-create", + "--content", `{"blocks":[{"type":"text","text":"test progress"}]}`, + "--target-id", "123456789", + "--target-type", "key_result", + "--progress-percent", "75", + "--progress-status", "normal", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v1/progress_records/"), "dry-run should contain API path, got: %s", output) +} + +// --- Progress Get Dry-run E2E tests --- + +// TestOKR_ProgressGetDryRun validates +progress-get dry-run output contains the correct method and API path. +func TestOKR_ProgressGetDryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+progress-get", + "--progress-id", "123456789", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v1/progress_records/123456789"), "dry-run should contain API path with progress-id, got: %s", output) + assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET method, got: %s", output) +} + +// TestOKR_ProgressGetDryRun_WithUserIDType validates +progress-get dry-run with user-id-type flag. +func TestOKR_ProgressGetDryRun_WithUserIDType(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+progress-get", + "--progress-id", "987654321", + "--user-id-type", "union_id", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v1/progress_records/987654321"), "dry-run should contain API path, got: %s", output) +} + +// --- Progress Update Dry-run E2E tests --- + +// TestOKR_ProgressUpdateDryRun validates +progress-update dry-run output contains the correct method and API path. +func TestOKR_ProgressUpdateDryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+progress-update", + "--progress-id", "123456789", + "--content", `{"blocks":[{"type":"text","text":"updated progress"}]}`, + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v1/progress_records/123456789"), "dry-run should contain API path with progress-id, got: %s", output) + assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output) +} + +// TestOKR_ProgressUpdateDryRun_WithProgress validates +progress-update dry-run with progress rate. +func TestOKR_ProgressUpdateDryRun_WithProgress(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+progress-update", + "--progress-id", "123456789", + "--content", `{"blocks":[{"type":"text","text":"updated progress"}]}`, + "--progress-percent", "100", + "--progress-status", "done", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v1/progress_records/123456789"), "dry-run should contain API path, got: %s", output) +} + +// --- Progress Delete Dry-run E2E tests --- + +// TestOKR_ProgressDeleteDryRun validates +progress-delete dry-run output contains the correct method and API path. +func TestOKR_ProgressDeleteDryRun(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+progress-delete", + "--progress-id", "123456789", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v1/progress_records/123456789"), "dry-run should contain API path with progress-id, got: %s", output) + assert.True(t, strings.Contains(output, "DELETE"), "dry-run should contain DELETE method, got: %s", output) +} + +// --- Progress List Dry-run E2E tests --- + +// TestOKR_ProgressListDryRun_Objective validates +progress-list dry-run for objective. +func TestOKR_ProgressListDryRun_Objective(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+progress-list", + "--target-id", "123456789", + "--target-type", "objective", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/123456789/progresses"), "dry-run should contain objective API path, got: %s", output) + assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET method, got: %s", output) +} + +// TestOKR_ProgressListDryRun_KeyResult validates +progress-list dry-run for key_result. +func TestOKR_ProgressListDryRun_KeyResult(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+progress-list", + "--target-id", "987654321", + "--target-type", "key_result", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v2/key_results/987654321/progresses"), "dry-run should contain key_result API path, got: %s", output) + assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET method, got: %s", output) +}