mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
feat: okr progress records (#574)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||
|
||||
@@ -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 目标、关键结果、对齐、量化指标、进展记录" }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
146
shortcuts/okr/okr_image_upload.go
Normal file
146
shortcuts/okr/okr_image_upload.go
Normal 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
|
||||
},
|
||||
}
|
||||
457
shortcuts/okr/okr_image_upload_test.go
Normal file
457
shortcuts/okr/okr_image_upload_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
235
shortcuts/okr/okr_progress_create.go
Normal file
235
shortcuts/okr/okr_progress_create.go
Normal 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
|
||||
},
|
||||
}
|
||||
339
shortcuts/okr/okr_progress_create_test.go
Normal file
339
shortcuts/okr/okr_progress_create_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
64
shortcuts/okr/okr_progress_delete.go
Normal file
64
shortcuts/okr/okr_progress_delete.go
Normal 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
|
||||
},
|
||||
}
|
||||
167
shortcuts/okr/okr_progress_delete_test.go
Normal file
167
shortcuts/okr/okr_progress_delete_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
103
shortcuts/okr/okr_progress_get.go
Normal file
103
shortcuts/okr/okr_progress_get.go
Normal 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
|
||||
}
|
||||
192
shortcuts/okr/okr_progress_get_test.go
Normal file
192
shortcuts/okr/okr_progress_get_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
164
shortcuts/okr/okr_progress_list.go
Normal file
164
shortcuts/okr/okr_progress_list.go
Normal 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
|
||||
},
|
||||
}
|
||||
311
shortcuts/okr/okr_progress_list_test.go
Normal file
311
shortcuts/okr/okr_progress_list_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
180
shortcuts/okr/okr_progress_update.go
Normal file
180
shortcuts/okr/okr_progress_update.go
Normal 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
|
||||
},
|
||||
}
|
||||
272
shortcuts/okr/okr_progress_update_test.go
Normal file
272
shortcuts/okr/okr_progress_update_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -12,5 +12,11 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
OKRListCycles,
|
||||
OKRCycleDetail,
|
||||
OKRListProgress,
|
||||
OKRGetProgressRecord,
|
||||
OKRCreateProgressRecord,
|
||||
OKRUpdateProgressRecord,
|
||||
OKRDeleteProgressRecord,
|
||||
OKRUploadImage,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -16,11 +16,11 @@ lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|-------------------------|----|--------|-----------------------------------------|
|
||||
| `--cycle-id <id>` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format <fmt>` | 否 | `json` | 输出格式。 |
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|--------------|----|--------|-----------------------------------------|
|
||||
| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
@@ -74,8 +74,9 @@ lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
其中,content 和 notes 字段是 JSON 字符串,为 OKR ContentBlock 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
|
||||
其中,content 和 notes 字段是 JSON 字符串,为 OKR ContentBlock
|
||||
富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -22,13 +22,13 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|-------------------------------|----|-----------|------------------------------------------------------------------|
|
||||
| `--user-id <id>` | 是 | — | OKR 所有者的用户 ID |
|
||||
| `--user-id-type <type>` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--time-range <range>` | 否 | — | 按时间范围过滤周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。留空获取所有周期。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format <fmt>` | 否 | `json` | 输出格式。 |
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|------------------|----|-----------|------------------------------------------------------------------|
|
||||
| `--user-id` | 是 | — | OKR 所有者的用户 ID |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--time-range` | 否 | — | 按时间范围过滤周期。格式:`YYYY-MM--YYYY-MM`(例如 `2025-01--2025-06`)。留空获取所有周期。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
@@ -64,10 +64,13 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run
|
||||
```
|
||||
|
||||
在这个周期信息中,这些字段值得关注:
|
||||
|
||||
- `id` 是这个周期的 ID,你通常需要用它在之后使用 `okr +cycle-detail` 获取 OKR 内容详情
|
||||
- `start_time` `end_time` 是周期的起止时间,总是从某个月1日开始,直到此月或之后某月的最后一日结束。
|
||||
- 在 OKR 系统中,我们只关注这个时间的年月部分,如 “2025-01-01开始,2025-06-30结束” 的周期被称作 “2025 年 1-6 月” 周期,而 “2025-01-01开始,2025-01-31结束” 的周期被称作 “2025 年 1 月”周期。
|
||||
- 如果一个周期从某年1月1日开始,某年12月31日结束,则它是这一年的年度周期,如 “2025-01-01开始,2025-12-31结束” 的周期就是 “2025 年” 的年度周期
|
||||
- 在 OKR 系统中,我们只关注这个时间的年月部分,如 “2025-01-01开始,2025-06-30结束” 的周期被称作 “2025 年 1-6 月” 周期,而
|
||||
“2025-01-01开始,2025-01-31结束” 的周期被称作 “2025 年 1 月”周期。
|
||||
- 如果一个周期从某年1月1日开始,某年12月31日结束,则它是这一年的年度周期,如 “2025-01-01开始,2025-12-31结束” 的周期就是
|
||||
“2025 年” 的年度周期
|
||||
- `cycle_status` 为周期状态值,参见下文。
|
||||
|
||||
### 周期状态值
|
||||
|
||||
@@ -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` | 是 | 进展记录 ID(int64,正整数) |
|
||||
| `modify_time` | `string` | 是 | 最后修改时间,毫秒时间戳,shortcut 会将其解析为日期时间 |
|
||||
| `content` | `ContentBlock` | 否 | 进展内容(富文本),见 [ContentBlock 定义](lark-okr-contentblock.md) |
|
||||
| `progress_rate` | `ProgressRate` | 否 | 进度率,包含百分比和状态 |
|
||||
|
||||
### ProgressRate (进度率)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----------|----------|----|------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `percent` | `number` | 否 | 进度百分比,范围 [-99999999999, 99999999999]。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 |
|
||||
| `status` | `string` | 否 | 进度状态,shortcut 返回可读字符串,见下表 |
|
||||
|
||||
### 进度状态 (progress_rate.status)
|
||||
|
||||
| 值 | 常量名 | 说明 |
|
||||
|-----------|-----|-------|
|
||||
| `normal` | 正常 | 进展正常 |
|
||||
| `overdue` | 逾期 | 进展逾期 |
|
||||
| `done` | 已完成 | 进展已完成 |
|
||||
|
||||
### 创建进展记录时的参数
|
||||
|
||||
创建进展记录时,除了 `content` 外,还需要指定这条进展记录挂载的对应目标或关键结果:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----------------|----------------|----|---------------------------------------------------------|
|
||||
| `content` | `ContentBlock` | 是 | 进展内容(富文本),见 [ContentBlock 定义](lark-okr-contentblock.md) |
|
||||
| `target_id` | `string` | 是 | 目标 ID 或关键结果 ID |
|
||||
| `target_type` | `integer` | 是 | 目标类型:`2`=目标(Objective),`3`=关键结果(KeyResult) |
|
||||
| `progress_rate` | `ProgressRate` | 否 | 进度率,可设置 `percent` 和 `status` |
|
||||
| `source_title` | `string` | 否 | 来源标题,用于在 OKR 界面中显示进展来源 |
|
||||
| `source_url` | `string` | 否 | 来源 URL,用于在 OKR 界面中显示进展来源链接 |
|
||||
|
||||
> **SHORTCUT:**
|
||||
> - `okr +progress-get` [lark-okr-progress-get.md](lark-okr-progress-get.md) 获取单条进展记录
|
||||
> - `okr +progress-create` [lark-okr-progress-create.md](lark-okr-progress-create.md) 为目标或关键结果创建进展记录
|
||||
> - `okr +progress-update` [lark-okr-progress-update.md](lark-okr-progress-update.md) 更新进展记录内容
|
||||
> - `okr +progress-delete` [lark-okr-progress-delete.md](lark-okr-progress-delete.md) 删除进展记录
|
||||
> - `okr +progress-list` [lark-okr-progress-list.md](lark-okr-progress-list.md) 获取目标/关键结果下的进展记录
|
||||
---
|
||||
|
||||
## Indicator (指标)
|
||||
|
||||
指标是目标和关键结果的量化度量,可独立挂载在 Objective 或 KeyResult 上。
|
||||
@@ -148,7 +198,8 @@ Category (分类): Objective 的分组标签
|
||||
|
||||
- **进度值**: 一般指 `current_value`,单位未提及时通常用百分制计算。
|
||||
- 当用户要求量化的更新 OKR 进度时,一般指的就是修改对应 OKR 的 Indicator。
|
||||
- OKR 在未设置量化指标时,Indicator 的内容为空。如果用户未做特别说明,更新进度时可以默认将进度以百分制设置(初始值0,目标值100,unit 参见下文设置为 0/PERCENT)
|
||||
- OKR 在未设置量化指标时,Indicator 的内容为空。如果用户未做特别说明,更新进度时可以默认将进度以百分制设置(初始值0,目标值100,unit
|
||||
参见下文设置为 0/PERCENT)
|
||||
|
||||
### 指标状态 (indicator_status)
|
||||
|
||||
@@ -254,12 +305,16 @@ Category (分类): Objective 的分组标签
|
||||
|
||||
## 权限 Scope 说明
|
||||
|
||||
| Scope | 权限类型 | 说明 |
|
||||
|-----------------------------|------|--------------|
|
||||
| `okr:okr.content:readonly` | 读 | 读取 OKR 内容 |
|
||||
| `okr:okr.content:writeonly` | 写 | 写入/删除 OKR 内容 |
|
||||
| `okr:okr.period:readonly` | 读 | 读取 OKR 周期 |
|
||||
| `okr:okr.setting:read` | 读 | 读取 OKR 设置 |
|
||||
| Scope | 权限类型 | 说明 |
|
||||
|--------------------------------|------|--------------|
|
||||
| `okr:okr.content:readonly` | 读 | 读取 OKR 内容 |
|
||||
| `okr:okr.content:writeonly` | 写 | 写入/删除 OKR 内容 |
|
||||
| `okr:okr.period:readonly` | 读 | 读取 OKR 周期 |
|
||||
| `okr:okr.progress:readonly` | 读 | 读取进展记录 |
|
||||
| `okr:okr.progress:writeonly` | 写 | 创建/更新进展记录 |
|
||||
| `okr:okr.progress:delete` | 写 | 删除进展记录 |
|
||||
| `okr:okr.progress.file:upload` | 写 | 上传进展记录图片附件 |
|
||||
| `okr:okr.setting:read` | 读 | 读取 OKR 设置 |
|
||||
|
||||
所有 OKR API 均支持 `user` 和 `tenant`(应用)两种 access token 类型。
|
||||
|
||||
@@ -268,3 +323,7 @@ Category (分类): Objective 的分组标签
|
||||
- [OKR ContentBlock 富文本格式](lark-okr-contentblock.md) — content/notes 字段的富文本结构定义
|
||||
- [okr +cycle-list](lark-okr-cycle-list.md) — 列出用户 OKR 周期
|
||||
- [okr +cycle-detail](lark-okr-cycle-detail.md) — 获取周期下的目标与关键结果
|
||||
- [okr +progress-get](lark-okr-progress-get.md) — 获取进展记录
|
||||
- [okr +progress-create](lark-okr-progress-create.md) — 创建进展记录
|
||||
- [okr +progress-update](lark-okr-progress-update.md) — 更新进展记录
|
||||
- [okr +progress-delete](lark-okr-progress-delete.md) — 删除进展记录
|
||||
|
||||
116
skills/lark-okr/references/lark-okr-image-upload.md
Normal file
116
skills/lark-okr/references/lark-okr-image-upload.md
Normal 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 或关键结果 ID(int64 类型,正整数) |
|
||||
| `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。
|
||||
2. 准备本地图片文件,确保格式受支持。
|
||||
3. 执行 `lark-cli okr +upload-image --file ./image.png --target-id "..." --target-type objective`。
|
||||
4. 获取返回的 `file_token`,用于构建 ContentBlock 中的图片内容。
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"file_token": "example-file-token",
|
||||
"url": "https://example.larksuite.com/download?file_token=example-file-token",
|
||||
"file_name": "screenshot.png",
|
||||
"size": 102400
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `file_token` — 用于在 ContentBlock 的 `ContentGallery` 中引用图片
|
||||
- `url` — 图片的访问 URL
|
||||
- `file_name` — 上传的文件名
|
||||
- `size` — 文件大小(字节)
|
||||
|
||||
## 在进展记录中使用上传的图片
|
||||
|
||||
上传图片后,将返回的 `file_token` 用于构建 ContentBlock 的图库块:
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "本周进展截图:"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"block_element_type": "gallery",
|
||||
"gallery": {
|
||||
"images": [
|
||||
{
|
||||
"file_token": "example-file-token",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
然后在创建或更新进展记录时使用此 ContentBlock:
|
||||
|
||||
```bash
|
||||
lark-cli okr +progress-create \
|
||||
--content @content_with_image.json \
|
||||
--target-id 1234567890123456789 \
|
||||
--target-type objective
|
||||
```
|
||||
|
||||
## 安全限制
|
||||
|
||||
- `--file` 参数**必须使用相对路径**(如 `./photo.png` 或 `images/photo.png`),不支持绝对路径
|
||||
- 图片文件必须存在于当前工作目录或其子目录中
|
||||
- 不支持符号链接指向目录外的文件
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口)
|
||||
- [ContentBlock 格式](lark-okr-contentblock.md) -- 进展内容使用的富文本格式,包含图片块的使用说明
|
||||
- [lark-okr-progress-create](lark-okr-progress-create.md) -- 创建进展记录
|
||||
- [lark-okr-progress-update](lark-okr-progress-update.md) -- 更新进展记录
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
81
skills/lark-okr/references/lark-okr-progress-create.md
Normal file
81
skills/lark-okr/references/lark-okr-progress-create.md
Normal 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 或关键结果 ID(int64 类型,正整数) |
|
||||
| `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` |
|
||||
| `--progress-percent` | 否 | — | 进度百分比(-99999999999 - 99999999999)。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 |
|
||||
| `--progress-status` | 否 | — | 进度状态:`normal`(正常) \| `overdue`(逾期) \| `done`(已完成)。仅在指定 `--progress-percent` 时生效。 |
|
||||
| `--source-title` | 否 | `created by lark-cli` | 来源标题,用于在 OKR 界面中显示进展来源 |
|
||||
| `--source-url` | 否 | 根据品牌自动生成 | 来源 URL,用于在 OKR 界面中显示进展来源链接,通常可以填写 OKR 编写信息来源的文档链接等。飞书品牌默认为 `https://open.feishu.cn/app`, Lark 品牌默认为 `https://open.larksuite.com/app` |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。
|
||||
2. 构造 ContentBlock JSON 格式的进展内容。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。
|
||||
3. 执行 `lark-cli okr +progress-create --content "..." --target-id "..." --target-type objective`。
|
||||
4. 报告结果:新创建的进展记录 ID、修改时间等。
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"progress": {
|
||||
"progress_id": "1234567890123456789",
|
||||
"modify_time": "2025-01-15 10:30:00",
|
||||
"content": "{...}",
|
||||
"progress_rate": {
|
||||
"percent": 80.0,
|
||||
"status": "done"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `content` 字段是 JSON 字符串,为 OKR ContentBlock
|
||||
富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口)
|
||||
- [ContentBlock 格式](lark-okr-contentblock.md) -- 进展内容使用的富文本格式
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
47
skills/lark-okr/references/lark-okr-progress-delete.md
Normal file
47
skills/lark-okr/references/lark-okr-progress-delete.md
Normal 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` | 是 | — | 进展记录 ID(int64 类型,正整数) |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+progress-get` 确认要删除的进展记录 ID 和内容。
|
||||
2. 执行 `lark-cli okr +progress-delete --progress-id "1234567890123456789"`。
|
||||
3. 报告结果:已删除的进展记录 ID。
|
||||
|
||||
> **注意**:此操作不可恢复,建议在删除前先用 `+progress-get` 确认记录内容。
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"deleted": true,
|
||||
"progress_id": "1234567890123456789"
|
||||
}
|
||||
```
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
62
skills/lark-okr/references/lark-okr-progress-get.md
Normal file
62
skills/lark-okr/references/lark-okr-progress-get.md
Normal 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` | 是 | — | 进展记录 ID(int64 类型,正整数) |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 获取目标进展记录的 ID。可通过 `+cycle-detail` 获取目标和关键结果后,从中获取进展记录 ID。
|
||||
2. 执行 `lark-cli okr +progress-get --progress-id "1234567890123456789"`。
|
||||
3. 报告结果:进展记录的 ID、修改时间、进度百分比和内容。
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"progress": {
|
||||
"progress_id": "1234567890123456789",
|
||||
"modify_time": "2025-01-15 10:30:00",
|
||||
"content": "{...}",
|
||||
"progress_rate": {
|
||||
"percent": 75.0,
|
||||
"status": "normal"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `content` 字段是 JSON 字符串,为 OKR ContentBlock
|
||||
富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
80
skills/lark-okr/references/lark-okr-progress-list.md
Normal file
80
skills/lark-okr/references/lark-okr-progress-list.md
Normal 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 或关键结果 ID(int64 类型,正整数) |
|
||||
| `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--department-id-type` | 否 | `open_department_id` | 部门 ID 类型:`department_id` \| `open_department_id` |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。
|
||||
2. 执行 `lark-cli okr +progress-list --target-id "..." --target-type objective`。
|
||||
3. 获取该目标或关键结果下的所有进展记录列表。
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"progress": [
|
||||
{
|
||||
"progress_id": "1234567890123456789",
|
||||
"modify_time": "2025-01-15 10:30:00",
|
||||
"content": "{...}",
|
||||
"progress_rate": {
|
||||
"percent": 80.0,
|
||||
"status": "done"
|
||||
}
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `progress` — 进展记录数组
|
||||
- `content` 字段是 JSON 字符串,为 OKR ContentBlock 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。
|
||||
|
||||
## 与 +progress-get 的区别
|
||||
|
||||
| 命令 | 用途 | API 版本 |
|
||||
|------------------|------------------------------------|----------|
|
||||
| `+progress-list` | 获取某个目标/关键结果的所有进展记录 | v2 |
|
||||
| `+progress-get` | 根据进展记录 ID 获取单条记录 | v1 |
|
||||
|
||||
`+progress-list` 返回的 `progress_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) -- 认证和全局参数
|
||||
81
skills/lark-okr/references/lark-okr-progress-update.md
Normal file
81
skills/lark-okr/references/lark-okr-progress-update.md
Normal 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` | 是 | — | 进展记录 ID(int64 类型,正整数) |
|
||||
| `--content` | 是 | — | 进展内容,ContentBlock JSON 格式。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 |
|
||||
| `--progress-percent` | 否 | — | 进度百分比(-99999999999 - 99999999999)。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 |
|
||||
| `--progress-status` | 否 | — | 进度状态:`normal`(正常) \| `overdue`(逾期) \| `done`(已完成)。仅在指定 `--progress-percent` 时生效。 |
|
||||
| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `+progress-get` 获取要更新的进展记录的 ID 和当前内容。
|
||||
2. 修改 ContentBlock JSON 格式的进展内容。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。
|
||||
3. 执行 `lark-cli okr +progress-update --progress-id "..." --content "..."`。
|
||||
4. 报告结果:更新后的进展记录 ID、修改时间、进度百分比等。
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"progress": {
|
||||
"progress_id": "1234567890123456789",
|
||||
"modify_time": "2025-01-15 14:30:00",
|
||||
"content": "{...}",
|
||||
"progress_rate": {
|
||||
"percent": 90.0,
|
||||
"status": "normal"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `content` 字段是 JSON 字符串,为 OKR ContentBlock
|
||||
富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
- `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口)
|
||||
- [ContentBlock 格式](lark-okr-contentblock.md) -- 进展内容使用的富文本格式
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
273
tests/cli_e2e/okr/okr_progress_test.go
Normal file
273
tests/cli_e2e/okr/okr_progress_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user