feat: okr progress records (#574)

This commit is contained in:
syh-cpdsss
2026-04-28 15:56:07 +08:00
committed by GitHub
parent 23066c8eee
commit 2e4cfb4921
33 changed files with 4449 additions and 49 deletions

View File

@@ -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. |
| 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) |
## Installation & Quick Start
@@ -156,6 +156,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

View File

@@ -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 调用只需三步
@@ -38,7 +38,7 @@
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐指标 |
| 🎯 OKR | 查询、创建、更新 OKR管理目标、关键结果、对齐指标和进展记录 |
| 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) |
## 安装与快速开始
@@ -157,6 +157,7 @@ lark-cli auth status
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |
| `lark-okr` | 查询、创建、更新 OKR管理目标、关键结果、对齐、指标和进展记录 |
## 认证

2
go.mod
View File

@@ -9,7 +9,7 @@ require (
github.com/gofrs/flock v0.8.1
github.com/google/uuid v1.6.0
github.com/itchyny/gojq v0.12.17
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/larksuite/oapi-sdk-go/v3 v3.5.4
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/smartystreets/goconvey v1.8.1
github.com/spf13/cobra v1.10.2

4
go.sum
View File

@@ -71,8 +71,8 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk=
github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0=
github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=

View File

@@ -65,7 +65,7 @@
"zh": { "title": "知识库", "description": "知识空间、节点管理" }
},
"okr": {
"en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators" },
"zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标" }
"en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators, progresses" },
"zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标、进展记录" }
}
}

View File

@@ -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"`
}

View File

@@ -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
},
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 }

View File

@@ -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
},
}

View File

@@ -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")
}
}

View File

@@ -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
},
}

View File

@@ -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")
}
}

View File

@@ -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
}

View File

@@ -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")
}
}

View File

@@ -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
},
}

View File

@@ -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_list"].([]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")
}
}

View File

@@ -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
},
}

View File

@@ -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")
}
}

View File

@@ -12,5 +12,11 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
OKRListCycles,
OKRCycleDetail,
OKRListProgress,
OKRGetProgressRecord,
OKRCreateProgressRecord,
OKRUpdateProgressRecord,
OKRDeleteProgressRecord,
OKRUploadImage,
}
}

View File

@@ -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 +<verb> [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 <resource> <method> [flags] # 调用 API
- `list` — 批量获取用户周期
- `objectives_position` — 更新用户周期下全部目标的位置
- 请求中必须同时修改对应周期下全部目标的位置,且不允许位置重叠,否则会参数校验失败。
- 请求中必须同时修改对应周期下全部目标的位置,且不允许位置重叠,否则会参数校验失败。
- `objectives_weight` — 更新用户周期下全部目标的权重
- 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
- 请求中必须同时修改对应周期下全部目标的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
### cycle.objectives
@@ -77,15 +83,15 @@ lark-cli okr <resource> <method> [flags] # 调用 API
- `delete` — 删除目标
- `get` — 获取目标
- `key_results_position` — 更新全部关键结果的位置
- 请求中必须同时修改对应目标下全部关键结果的位置,且不允许位置重叠,否则会参数校验失败。
- 请求中必须同时修改对应目标下全部关键结果的位置,且不允许位置重叠,否则会参数校验失败。
- `key_results_weight` — 更新全部关键结果的权重
- 请求中必须同时修改对应目标下全部关键结果的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
- 请求中必须同时修改对应目标下全部关键结果的权重,且所有权重值的和必须等于 1 ,否则会参数校验失败。
- `patch` — 更新目标
### objective.alignments
- `create` — 创建对齐关系
- 对齐不允许对齐自己的目标,且发起对齐的目标和被对齐的目标所在周期时间上必须有重叠,否则会参数校验失败。
- 对齐不允许对齐自己的目标,且发起对齐的目标和被对齐的目标所在周期时间上必须有重叠,否则会参数校验失败。
- `list` — 批量获取目标下的对齐关系
### objective.indicators

View File

@@ -168,7 +168,10 @@ OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock`
### ContentGallery
片块。目前仅有进展记录中的富文本支持展示图片
由于 OKR 应用中进展页面的布局排版限制,一个 ContentGallery 元素中**仅可放置一个图片元素**,需要插入多张图片时需使用多个 ContentGallery 元素
(同一个 ContentGallery 中添加多个 image 会导致这些图片在狭窄的横向排版空间中互相挤占,效果很差)
| 字段 | 类型 | 说明 |
|----------|----------------------|-------|
@@ -185,6 +188,8 @@ OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock`
| `width` | `float64` | 宽度 |
| `height` | `float64` | 高度 |
> **如何获取 `file_token`** 使用 [`+upload-image`](lark-okr-image-upload.md) 命令上传本地图片,返回的 `file_token` 可用于构建 `ContentGallery` 图片块。
### ContentDocsLink
飞书文档链接。
@@ -311,3 +316,44 @@ OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock`
]
}
```
### 示例 4带用户提及和图片仅进展记录支持的段落
```json
{
"blocks": [
{
"block_element_type": "paragraph",
"paragraph": {
"elements": [
{
"paragraph_element_type": "mention",
"mention": {
"user_id": "ou_example_user"
}
},
{
"paragraph_element_type": "textRun",
"text_run": {
"text": " 请关注此进度并查看以下图片"
}
}
]
}
},
{
"block_element_type": "gallery",
"gallery": {
"images": [
{
"file_token": "img_example_token",
"src": "https://example.com/image.png",
"width": 800,
"height": 600
}
]
}
}
]
}
```

View File

@@ -16,11 +16,11 @@ lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|-------------------------|----|--------|-----------------------------------------|
| `--cycle-id &lt;id&gt;` | 是 | — | OKR 周期 IDint64 类型)。从 `+cycle-list` 获取。 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
| `--format <fmt>` | 否 | `json` | 输出格式。 |
| 参数 | 必填 | 默认值 | 说明 |
|--------------|----|--------|-----------------------------------------|
| `--cycle-id` | 是 | — | OKR 周期 IDint64 类型)。从 `+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) 了解详细信息。
## 参考

View File

@@ -22,13 +22,13 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|-------------------------------|----|-----------|------------------------------------------------------------------|
| `--user-id &lt;id&gt;` | 是 | — | OKR 所有者的用户 ID |
| `--user-id-type &lt;type&gt;` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
| `--time-range &lt;range&gt;` | 否 | — | 按时间范围过滤周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。留空获取所有周期。 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
| `--format &lt;fmt&gt;` | 否 | `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` 为周期状态值,参见下文。
### 周期状态值

View File

@@ -9,7 +9,9 @@ Cycle (用户周期)
└── Objective (目标)
├── KeyResult (关键结果)
│ └── Indicator (指标)
│ └── list<Progress> (进展记录列表)
└── Indicator (指标)
└── list<Progress> (进展记录列表)
Alignment (对齐关系): Objective ↔ Objective
Category (分类): Objective 的分组标签
@@ -46,7 +48,8 @@ Category (分类): Objective 的分组标签
### 常用术语
- **当前周期**: 指周期的 start_time/end_time 所在的时间段与当前时间重叠的周期。如果有多个符合这一标准的周期通常可以选择周期状态为default/normal的周期其中较新的一个。当用户提及“上一个周期”“下一个周期”一类的表述时通常是以当前周期为准计算。
- **当前周期**: 指周期的 start_time/end_time
指周期的 start_time / end_time 所在的时间段与当前时间重叠的周期(即: start_time <= 当前时间 且 end_time >= 当前时间)。 注意:时间重叠是判断当前周期的首要且必须的硬性条件,绝对不能仅仅根据 cycle_status == 1 去判断。 如果有多个符合时间重叠标准的周期,再在这些包含当前时间的周期中过滤,保留周期状态为 default (0) 或 normal (1) 的周期。如果仍然有多个,则选择其中较新的一个。当用户提及“上一个周期”,“下一个周期”一类的表述时,通常是以当前周期为准计算。
- **所有者**: 绝大多数所有者都是用户少部分租户启用了“团队OKR”功能所有者可能是部门。用户身份下只能编辑所有者为当前用户的
OKR。
@@ -124,6 +127,53 @@ Category (分类): Objective 的分组标签
---
## Progress (进展记录)
进展记录挂载在目标Objective或关键结果Key Result用于记录阶段性进展内容与进度百分比。每条进展记录包含富文本内容和可选的进度率。
| 字段 | 类型 | 必填 | 说明 |
|-----------------|----------------|----|---------------------------------------------------------|
| `progress_id` | `string` | 是 | 进展记录 IDint64正整数 |
| `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目标值100unit 参见下文设置为 0/PERCENT
- OKR 在未设置量化指标时Indicator 的内容为空。如果用户未做特别说明更新进度时可以默认将进度以百分制设置初始值0目标值100unit
参见下文设置为 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) — 删除进展记录

View File

@@ -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 或关键结果 IDint64 类型,正整数) |
| `--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) -- 认证和全局参数

View File

@@ -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 或关键结果 IDint64 类型,正整数) |
| `--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) -- 认证和全局参数

View File

@@ -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` | 是 | — | 进展记录 IDint64 类型,正整数) |
| `--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) -- 认证和全局参数

View File

@@ -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` | 是 | — | 进展记录 IDint64 类型,正整数) |
| `--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) -- 认证和全局参数

View File

@@ -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 或关键结果 IDint64 类型,正整数) |
| `--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_list` 数组中每条记录的结构与 `+progress-get` 返回的 `progress` 结构相同。
## 参考
- [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) -- 认证和全局参数

View File

@@ -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` | 是 | — | 进展记录 IDint64 类型,正整数) |
| `--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) -- 认证和全局参数

View File

@@ -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)
}