mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
Feat: Add OKR business domain (#522)
* feat: okr domain Change-Id: I1877c56e33e3620b696351ed9e4c8615dbe17c4b * feat: okr skill update Change-Id: I1877c56e33e3620b696351ed9e4c8615dbe17c4b
This commit is contained in:
@@ -30,7 +30,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
|
||||
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
|
||||
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
|
||||
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
|
||||
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
|
||||
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
|
||||
| 👤 Contact | Search users by name/email/phone, get user profiles |
|
||||
@@ -38,6 +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. |
|
||||
|
||||
## Installation & Quick Start
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
|
||||
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
|
||||
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
|
||||
| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 |
|
||||
|
||||
## 安装与快速开始
|
||||
|
||||
|
||||
@@ -63,5 +63,9 @@
|
||||
"wiki": {
|
||||
"en": { "title": "Wiki", "description": "Wiki space and node management" },
|
||||
"zh": { "title": "知识库", "description": "知识空间、节点管理" }
|
||||
},
|
||||
"okr": {
|
||||
"en": { "title": "OKR", "description": "Lark OKR objectives, key results, alignments, indicators" },
|
||||
"zh": { "title": "OKR", "description": "飞书 OKR 目标、关键结果、对齐、量化指标" }
|
||||
}
|
||||
}
|
||||
|
||||
101
shortcuts/okr/okr_cli_resp.go
Normal file
101
shortcuts/okr/okr_cli_resp.go
Normal file
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
// RespAlignment 对齐关系
|
||||
type RespAlignment struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
FromOwner RespOwner `json:"from_owner"`
|
||||
ToOwner RespOwner `json:"to_owner"`
|
||||
FromEntityType string `json:"from_entity_type"`
|
||||
FromEntityID string `json:"from_entity_id"`
|
||||
ToEntityType string `json:"to_entity_type"`
|
||||
ToEntityID string `json:"to_entity_id"`
|
||||
}
|
||||
|
||||
// RespCategory 分类
|
||||
type RespCategory struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
CategoryType string `json:"category_type"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Color *string `json:"color,omitempty"`
|
||||
Name CategoryName `json:"name"`
|
||||
}
|
||||
|
||||
// RespCycle 周期
|
||||
type RespCycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *string `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicator 指标
|
||||
type RespIndicator struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
EntityType *string `json:"entity_type,omitempty"`
|
||||
EntityID *string `json:"entity_id,omitempty"`
|
||||
IndicatorStatus *string `json:"indicator_status,omitempty"`
|
||||
StatusCalculateType *string `json:"status_calculate_type,omitempty"`
|
||||
StartValue *float64 `json:"start_value,omitempty"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
CurrentValue *float64 `json:"current_value,omitempty"`
|
||||
CurrentValueCalculateType *string `json:"current_value_calculate_type,omitempty"`
|
||||
Unit *RespIndicatorUnit `json:"unit,omitempty"`
|
||||
}
|
||||
|
||||
// RespIndicatorUnit 指标单位
|
||||
type RespIndicatorUnit struct {
|
||||
UnitType *string `json:"unit_type,omitempty"`
|
||||
UnitValue *string `json:"unit_value,omitempty"`
|
||||
}
|
||||
|
||||
// RespKeyResult 关键结果
|
||||
type RespKeyResult struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// RespObjective 目标
|
||||
type RespObjective struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner RespOwner `json:"owner"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *string `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
KeyResults []RespKeyResult `json:"key_results,omitempty"`
|
||||
}
|
||||
|
||||
// RespOwner OKR 所有者
|
||||
type RespOwner struct {
|
||||
OwnerType string `json:"owner_type"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
182
shortcuts/okr/okr_cycle_detail.go
Normal file
182
shortcuts/okr/okr_cycle_detail.go
Normal file
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// OKRCycleDetail lists all objectives and their key results under a given OKR cycle.
|
||||
var OKRCycleDetail = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-detail",
|
||||
Description: "List objectives and key results under an OKR cycle",
|
||||
Risk: "read",
|
||||
Scopes: []string{"okr:okr.content:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if cycleID == "" {
|
||||
return common.FlagErrorf("--cycle-id is required")
|
||||
}
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return common.FlagErrorf("--cycle-id must be a positive int64")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
params := map[string]interface{}{
|
||||
"page_size": 100,
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/okr/v2/cycles/:cycle_id/objectives").
|
||||
Params(params).
|
||||
Set("cycle_id", cycleID).
|
||||
Desc("Auto-paginates objectives in the cycle, then calls GET /open-apis/okr/v2/objectives/:objective_id/key_results for each objective to fetch key results")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
|
||||
// Paginate objectives under the cycle.
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("page_size", "100")
|
||||
|
||||
var objectives []Objective
|
||||
page := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
|
||||
data, err := runtime.DoAPIJSON("GET", path, 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 obj Objective
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
continue
|
||||
}
|
||||
objectives = append(objectives, obj)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
// For each objective, paginate key results and convert to response format.
|
||||
respObjectives := make([]*RespObjective, 0, len(objectives))
|
||||
for i := range objectives {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
obj := &objectives[i]
|
||||
|
||||
krQuery := make(larkcore.QueryParams)
|
||||
krQuery.Set("page_size", "100")
|
||||
|
||||
var keyResults []KeyResult
|
||||
krPage := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if krPage > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
krPage++
|
||||
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID)
|
||||
data, err := runtime.DoAPIJSON("GET", path, krQuery, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var kr KeyResult
|
||||
if err := json.Unmarshal(raw, &kr); err != nil {
|
||||
continue
|
||||
}
|
||||
keyResults = append(keyResults, kr)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
krQuery.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
respObj := obj.ToResp()
|
||||
if respObj == nil {
|
||||
continue
|
||||
}
|
||||
respKRs := make([]RespKeyResult, 0, len(keyResults))
|
||||
for j := range keyResults {
|
||||
if r := keyResults[j].ToResp(); r != nil {
|
||||
respKRs = append(respKRs, *r)
|
||||
}
|
||||
}
|
||||
respObj.KeyResults = respKRs
|
||||
respObjectives = append(respObjectives, respObj)
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"objectives": respObjectives,
|
||||
"total": len(respObjectives),
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
|
||||
for _, o := range respObjectives {
|
||||
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
|
||||
for _, kr := range o.KeyResults {
|
||||
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
561
shortcuts/okr/okr_cycle_detail_test.go
Normal file
561
shortcuts/okr/okr_cycle_detail_test.go
Normal file
@@ -0,0 +1,561 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func cycleDetailTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
replacer := strings.NewReplacer("/", "-", " ", "-")
|
||||
suffix := replacer.Replace(strings.ToLower(t.Name()))
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-detail-" + suffix,
|
||||
AppSecret: "secret-okr-detail-" + suffix,
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runCycleDetailShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRCycleDetail.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func decodeEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in output envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestCycleDetailValidate_MissingCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing --cycle-id")
|
||||
}
|
||||
// cobra catches required flag before our Validate runs
|
||||
if !strings.Contains(err.Error(), "cycle-id") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_NonNumeric(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "abc"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-numeric --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_Zero(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "0"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for zero --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_InvalidCycleID_Negative(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "-1"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for negative --cycle-id")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--cycle-id must be a positive int64") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailValidate_ValidCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
// Need to register stubs because Validate passes and Execute runs
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "123"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestCycleDetailDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{
|
||||
"+cycle-detail",
|
||||
"--cycle-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 cycle-id 456, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles/456/objectives") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestCycleDetailExecute_NoObjectives(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/100/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "100"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["cycle_id"] != "100" {
|
||||
t.Fatalf("cycle_id = %v, want 100", data["cycle_id"])
|
||||
}
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 0 {
|
||||
t.Fatalf("objectives = %v, want empty", objs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_WithObjectivesAndKeyResults(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
|
||||
// Stub for objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/200/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "200",
|
||||
"score": 0.8,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{
|
||||
"text": "Improve team productivity",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Stub for key results of obj-1
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-1",
|
||||
"objective_id": "obj-1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.9,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{
|
||||
"text": "Reduce response time by 50%",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "200"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeEnvelope(t, stdout)
|
||||
if data["cycle_id"] != "200" {
|
||||
t.Fatalf("cycle_id = %v, want 200", data["cycle_id"])
|
||||
}
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 1 {
|
||||
t.Fatalf("objectives count = %d, want 1", len(objs))
|
||||
}
|
||||
obj, _ := objs[0].(map[string]interface{})
|
||||
if obj["id"] != "obj-1" {
|
||||
t.Fatalf("objective id = %v, want obj-1", obj["id"])
|
||||
}
|
||||
krs, _ := obj["key_results"].([]interface{})
|
||||
if len(krs) != 1 {
|
||||
t.Fatalf("key results count = %d, want 1", len(krs))
|
||||
}
|
||||
kr, _ := krs[0].(map[string]interface{})
|
||||
if kr["id"] != "kr-1" {
|
||||
t.Fatalf("key result id = %v, want kr-1", kr["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
|
||||
// First page of objectives
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/300/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "300",
|
||||
"score": 0.5,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "Page1 obj"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next_page_token",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Second page of objectives (no more)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/300/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"cycle_id": "300",
|
||||
"score": 0.6,
|
||||
"weight": 1.0,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "Page2 obj"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Key results for obj-p1: first page with has_more=true
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p1-1",
|
||||
"objective_id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.7,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 1 for obj-p1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "kr-p1-next",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Key results for obj-p1: second page with has_more=false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p1/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p1-2",
|
||||
"objective_id": "obj-p1",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.8,
|
||||
"weight": 0.5,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 2 for obj-p1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Key results for obj-p2: first page with has_more=true
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p2/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p2-1",
|
||||
"objective_id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.6,
|
||||
"weight": 0.4,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 1 for obj-p2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "kr-p2-next",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Key results for obj-p2: second page with has_more=false
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/obj-p2/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "kr-p2-2",
|
||||
"objective_id": "obj-p2",
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"score": 0.9,
|
||||
"weight": 0.6,
|
||||
"content": map[string]interface{}{
|
||||
"blocks": []interface{}{
|
||||
map[string]interface{}{
|
||||
"block_type": 1,
|
||||
"paragraph": map[string]interface{}{
|
||||
"elements": []interface{}{
|
||||
map[string]interface{}{
|
||||
"element_type": 1,
|
||||
"text_run": map[string]interface{}{"text": "KR page 2 for obj-p2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "300"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeEnvelope(t, stdout)
|
||||
objs, _ := data["objectives"].([]interface{})
|
||||
if len(objs) != 2 {
|
||||
t.Fatalf("objectives count = %d, want 2", len(objs))
|
||||
}
|
||||
|
||||
// Verify key_results are aggregated across pages for each objective
|
||||
for i, objRaw := range objs {
|
||||
obj, _ := objRaw.(map[string]interface{})
|
||||
objID, _ := obj["id"].(string)
|
||||
krs, _ := obj["key_results"].([]interface{})
|
||||
if len(krs) != 2 {
|
||||
t.Fatalf("objective[%d] %s: key_results count = %d, want 2", i, objID, len(krs))
|
||||
}
|
||||
// Verify KR IDs are distinct (from different pages)
|
||||
krIDs := make(map[string]bool)
|
||||
for _, krRaw := range krs {
|
||||
kr, _ := krRaw.(map[string]interface{})
|
||||
krID, _ := kr["id"].(string)
|
||||
krIDs[krID] = true
|
||||
}
|
||||
if len(krIDs) != 2 {
|
||||
t.Fatalf("objective %s: expected 2 distinct KR IDs, got %v", objID, krIDs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleDetailExecute_APIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleDetailTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles/400/objectives",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "internal error",
|
||||
},
|
||||
})
|
||||
err := runCycleDetailShortcut(t, f, stdout, []string{"+cycle-detail", "--cycle-id", "400"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
189
shortcuts/okr/okr_cycle_list.go
Normal file
189
shortcuts/okr/okr_cycle_list.go
Normal file
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// parseTimeRange parses a "YYYY-MM--YYYY-MM" string into two time.Time values.
|
||||
// The start is the first moment of the start month; the end is the last moment of the end month.
|
||||
func parseTimeRange(s string) (start, end time.Time, err error) {
|
||||
parts := strings.SplitN(s, "--", 2)
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid time-range format %q, expected YYYY-MM--YYYY-MM", s)
|
||||
}
|
||||
start, err = time.Parse("2006-01", strings.TrimSpace(parts[0]))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid start month %q: %w", parts[0], err)
|
||||
}
|
||||
end, err = time.Parse("2006-01", strings.TrimSpace(parts[1]))
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("invalid end month %q: %w", parts[1], err)
|
||||
}
|
||||
// end is the last moment of the end month
|
||||
end = end.AddDate(0, 1, 0).Add(-time.Millisecond)
|
||||
if start.After(end) {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("start month %s is after end month %s", parts[0], parts[1])
|
||||
}
|
||||
return start, end, nil
|
||||
}
|
||||
|
||||
// cycleOverlaps checks whether a cycle's [startMs, endMs] overlaps with [rangeStart, rangeEnd].
|
||||
func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
|
||||
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
|
||||
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
|
||||
if err1 != nil || err2 != nil {
|
||||
return false
|
||||
}
|
||||
cycleStart := time.UnixMilli(startMs)
|
||||
cycleEnd := time.UnixMilli(endMs)
|
||||
// Two ranges overlap iff one starts before the other ends
|
||||
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
|
||||
}
|
||||
|
||||
var OKRListCycles = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-list",
|
||||
Description: "List okr cycles of a certain user",
|
||||
Risk: "read",
|
||||
Scopes: []string{"okr:okr.period:readonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Desc: "user ID", Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "time-range", Desc: "specify time range. Use Format as YYYY-MM--YYYY-MM. leave empty to fetch all user cycles."},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
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")
|
||||
}
|
||||
userID := runtime.Str("user-id")
|
||||
if err := validate.RejectControlChars(userID, "user-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tr := runtime.Str("time-range")
|
||||
if tr != "" {
|
||||
if err := validate.RejectControlChars(tr, "time-range"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, _, err := parseTimeRange(tr); err != nil {
|
||||
return common.FlagErrorf("--time-range: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{
|
||||
"user_id": runtime.Str("user-id"),
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"page_size": 100,
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/okr/v2/cycles").
|
||||
Params(params).
|
||||
Desc("List OKR cycles for user, paginated at 100 per page, filtered by time-range")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
userID := runtime.Str("user-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
timeRange := runtime.Str("time-range")
|
||||
|
||||
// Parse time range for filtering
|
||||
var rangeStart, rangeEnd time.Time
|
||||
var hasRange bool
|
||||
if timeRange != "" {
|
||||
var err error
|
||||
rangeStart, rangeEnd, err = parseTimeRange(timeRange)
|
||||
if err != nil {
|
||||
return common.FlagErrorf("--time-range: %s", err)
|
||||
}
|
||||
hasRange = true
|
||||
}
|
||||
|
||||
// Paginated fetch of all cycles
|
||||
queryParams := make(larkcore.QueryParams)
|
||||
queryParams.Set("user_id", userID)
|
||||
queryParams.Set("user_id_type", userIDType)
|
||||
queryParams.Set("page_size", "100")
|
||||
|
||||
var allCycles []Cycle
|
||||
page := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
data, err := runtime.DoAPIJSON("GET", "/open-apis/okr/v2/cycles", 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 cycle Cycle
|
||||
if err := json.Unmarshal(raw, &cycle); err != nil {
|
||||
continue
|
||||
}
|
||||
allCycles = append(allCycles, cycle)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams.Set("page_token", pageToken)
|
||||
}
|
||||
|
||||
// Filter by time-range overlap
|
||||
var filtered []Cycle
|
||||
for i := range allCycles {
|
||||
if !hasRange || cycleOverlaps(&allCycles[i], rangeStart, rangeEnd) {
|
||||
filtered = append(filtered, allCycles[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to response format
|
||||
respCycles := make([]*RespCycle, 0, len(filtered))
|
||||
for i := range filtered {
|
||||
respCycles = append(respCycles, filtered[i].ToResp())
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||
for _, c := range respCycles {
|
||||
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
448
shortcuts/okr/okr_cycle_list_test.go
Normal file
448
shortcuts/okr/okr_cycle_list_test.go
Normal file
@@ -0,0 +1,448 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func cycleListTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
replacer := strings.NewReplacer("/", "-", " ", "-")
|
||||
suffix := replacer.Replace(strings.ToLower(t.Name()))
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-list-" + suffix,
|
||||
AppSecret: "secret-okr-list-" + suffix,
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runCycleListShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRListCycles.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestCycleListValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--user-id-type", "invalid_type",
|
||||
})
|
||||
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 TestCycleListValidate_ControlCharsInUserID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-\t123",
|
||||
"--user-id-type", "open_id",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for control chars in --user-id")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ControlCharsInTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--user-id-type", "open_id",
|
||||
"--time-range", "2025-01\t--2025-06",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for control chars in --time-range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_InvalidTimeRangeFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01-2025-06",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --time-range format")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--time-range") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_StartAfterEndTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-06--2025-01",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for start after end in --time-range")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "--time-range") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ValidNoTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ValidWithTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_AllUserIDTypes(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, idType := range []string{"open_id", "union_id", "user_id"} {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "test-id",
|
||||
"--user-id-type", idType,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("user-id-type=%q: unexpected error: %v", idType, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestCycleListDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-456",
|
||||
"--user-id-type", "open_id",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "ou-456") {
|
||||
t.Fatalf("dry-run output should contain user-id ou-456, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-789",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestCycleListExecute_NoCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 0 {
|
||||
t.Fatalf("cycles = %v, want empty", cycles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1751318400000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-1",
|
||||
"score": 0.75,
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-2",
|
||||
"start_time": "1704067200000",
|
||||
"end_time": "1719792000000",
|
||||
"cycle_status": 2,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
"tenant_cycle_id": "tc-2",
|
||||
"score": 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
total, _ := data["total"].(float64)
|
||||
if int(total) != 2 {
|
||||
t.Fatalf("total = %v, want 2", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// Return two cycles: one inside the range, one outside
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-in-range",
|
||||
"start_time": "1735689600000", // 2025-01-01
|
||||
"end_time": "1738368000000", // 2025-02-01
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": "cycle-out-range",
|
||||
"start_time": "1704067200000", // 2024-01-01
|
||||
"end_time": "1706745600000", // 2024-02-01
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 1 {
|
||||
t.Fatalf("cycles count = %d, want 1 (only cycle-in-range should pass filter)", len(cycles))
|
||||
}
|
||||
cycle, _ := cycles[0].(map[string]interface{})
|
||||
if cycle["id"] != "cycle-in-range" {
|
||||
t.Fatalf("cycle id = %v, want cycle-in-range", cycle["id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// First page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-p1",
|
||||
"start_time": "1735689600000",
|
||||
"end_time": "1738368000000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next_page",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Second page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-p2",
|
||||
"start_time": "1738368000000",
|
||||
"end_time": "1743465600000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListExecute_APIError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Status: 500,
|
||||
Body: map[string]interface{}{
|
||||
"code": 999,
|
||||
"msg": "internal error",
|
||||
},
|
||||
})
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for API failure")
|
||||
}
|
||||
}
|
||||
361
shortcuts/okr/okr_openapi.go
Normal file
361
shortcuts/okr/okr_openapi.go
Normal file
@@ -0,0 +1,361 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CycleStatus 周期状态
|
||||
type CycleStatus int32
|
||||
|
||||
const (
|
||||
CycleStatusDefault CycleStatus = 0
|
||||
CycleStatusNormal CycleStatus = 1
|
||||
CycleStatusInvalid CycleStatus = 2
|
||||
CycleStatusHidden CycleStatus = 3
|
||||
)
|
||||
|
||||
func (t CycleStatus) Ptr() *CycleStatus { return &t }
|
||||
|
||||
// StatusCalculateType 状态计算类型
|
||||
type StatusCalculateType int32
|
||||
|
||||
const (
|
||||
StatusCalculateTypeManualUpdate StatusCalculateType = 0
|
||||
StatusCalculateTypeAutomaticallyUpdatesBasedOnProgressAndCurrentTime StatusCalculateType = 1
|
||||
StatusCalculateTypeStatusUpdatesBasedOnTheHighestRiskKeyResults StatusCalculateType = 2
|
||||
)
|
||||
|
||||
// BlockElementType 块元素类型
|
||||
type BlockElementType string
|
||||
|
||||
const (
|
||||
BlockElementTypeGallery BlockElementType = "gallery"
|
||||
BlockElementTypeParagraph BlockElementType = "paragraph"
|
||||
)
|
||||
|
||||
func (t BlockElementType) Ptr() *BlockElementType { return &t }
|
||||
|
||||
// CategoryName 分类名称
|
||||
type CategoryName struct {
|
||||
Zh *string `json:"zh,omitempty"`
|
||||
En *string `json:"en,omitempty"`
|
||||
Ja *string `json:"ja,omitempty"`
|
||||
}
|
||||
|
||||
// ListType 列表类型
|
||||
type ListType string
|
||||
|
||||
const (
|
||||
ListTypeBullet ListType = "bullet"
|
||||
ListTypeCheckBox ListType = "checkBox"
|
||||
ListTypeCheckedBox ListType = "checkedBox"
|
||||
ListTypeIndent ListType = "indent"
|
||||
ListTypeNumber ListType = "number"
|
||||
)
|
||||
|
||||
// OwnerType 所有者类型
|
||||
type OwnerType string
|
||||
|
||||
const (
|
||||
OwnerTypeDepartment OwnerType = "department"
|
||||
OwnerTypeUser OwnerType = "user"
|
||||
)
|
||||
|
||||
// ParagraphElementType 段落元素类型
|
||||
type ParagraphElementType string
|
||||
|
||||
const (
|
||||
ParagraphElementTypeDocsLink ParagraphElementType = "docsLink"
|
||||
ParagraphElementTypeMention ParagraphElementType = "mention"
|
||||
ParagraphElementTypeTextRun ParagraphElementType = "textRun"
|
||||
)
|
||||
|
||||
func (t ParagraphElementType) Ptr() *ParagraphElementType { return &t }
|
||||
|
||||
// ContentBlock 内容块
|
||||
type ContentBlock struct {
|
||||
Blocks []ContentBlockElement `json:"blocks,omitempty"`
|
||||
}
|
||||
|
||||
// ContentBlockElement 内容块元素
|
||||
type ContentBlockElement struct {
|
||||
BlockElementType *BlockElementType `json:"block_element_type,omitempty"`
|
||||
Paragraph *ContentParagraph `json:"paragraph,omitempty"`
|
||||
Gallery *ContentGallery `json:"gallery,omitempty"`
|
||||
}
|
||||
|
||||
// ContentColor 颜色
|
||||
type ContentColor struct {
|
||||
Red *int32 `json:"red,omitempty"`
|
||||
Green *int32 `json:"green,omitempty"`
|
||||
Blue *int32 `json:"blue,omitempty"`
|
||||
Alpha *float64 `json:"alpha,omitempty"`
|
||||
}
|
||||
|
||||
// ContentDocsLink 文档链接
|
||||
type ContentDocsLink struct {
|
||||
URL *string `json:"url,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// ContentGallery 图库
|
||||
type ContentGallery struct {
|
||||
Images []ContentImageItem `json:"images,omitempty"`
|
||||
}
|
||||
|
||||
// ContentImageItem 图片项
|
||||
type ContentImageItem struct {
|
||||
FileToken *string `json:"file_token,omitempty"`
|
||||
Src *string `json:"src,omitempty"`
|
||||
Width *float64 `json:"width,omitempty"`
|
||||
Height *float64 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
// ContentLink 链接
|
||||
type ContentLink struct {
|
||||
URL *string `json:"url,omitempty"`
|
||||
}
|
||||
|
||||
// ContentList 列表
|
||||
type ContentList struct {
|
||||
ListType *ListType `json:"list_type,omitempty"`
|
||||
IndentLevel *int32 `json:"indent_level,omitempty"`
|
||||
Number *int32 `json:"number,omitempty"`
|
||||
}
|
||||
|
||||
// ContentMention 提及
|
||||
type ContentMention struct {
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraph 段落
|
||||
type ContentParagraph struct {
|
||||
Style *ContentParagraphStyle `json:"style,omitempty"`
|
||||
Elements []ContentParagraphElement `json:"elements,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraphElement 段落元素
|
||||
type ContentParagraphElement struct {
|
||||
ParagraphElementType *ParagraphElementType `json:"paragraph_element_type,omitempty"`
|
||||
TextRun *ContentTextRun `json:"text_run,omitempty"`
|
||||
DocsLink *ContentDocsLink `json:"docs_link,omitempty"`
|
||||
Mention *ContentMention `json:"mention,omitempty"`
|
||||
}
|
||||
|
||||
// ContentParagraphStyle 段落样式
|
||||
type ContentParagraphStyle struct {
|
||||
List *ContentList `json:"list,omitempty"`
|
||||
}
|
||||
|
||||
// ContentTextRun 文本块
|
||||
type ContentTextRun struct {
|
||||
Text *string `json:"text,omitempty"`
|
||||
Style *ContentTextStyle `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// ContentTextStyle 文本样式
|
||||
type ContentTextStyle struct {
|
||||
Bold *bool `json:"bold,omitempty"`
|
||||
StrikeThrough *bool `json:"strike_through,omitempty"`
|
||||
BackColor *ContentColor `json:"back_color,omitempty"`
|
||||
TextColor *ContentColor `json:"text_color,omitempty"`
|
||||
Link *ContentLink `json:"link,omitempty"`
|
||||
}
|
||||
|
||||
// Cycle 周期
|
||||
type Cycle struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
TenantCycleID string `json:"tenant_cycle_id"`
|
||||
Owner Owner `json:"owner"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time"`
|
||||
CycleStatus *CycleStatus `json:"cycle_status,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
}
|
||||
|
||||
// KeyResult 关键结果
|
||||
type KeyResult struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner Owner `json:"owner"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
}
|
||||
|
||||
// Objective 目标
|
||||
type Objective struct {
|
||||
ID string `json:"id"`
|
||||
CreateTime string `json:"create_time"`
|
||||
UpdateTime string `json:"update_time"`
|
||||
Owner Owner `json:"owner"`
|
||||
CycleID string `json:"cycle_id"`
|
||||
Position *int32 `json:"position,omitempty"`
|
||||
Content *ContentBlock `json:"content,omitempty"`
|
||||
Score *float64 `json:"score,omitempty"`
|
||||
Notes *ContentBlock `json:"notes,omitempty"`
|
||||
Weight *float64 `json:"weight,omitempty"`
|
||||
Deadline *string `json:"deadline,omitempty"`
|
||||
CategoryID *string `json:"category_id,omitempty"`
|
||||
}
|
||||
|
||||
// Owner OKR 所有者
|
||||
type Owner struct {
|
||||
OwnerType OwnerType `json:"owner_type"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// ToString CycleStatus to string
|
||||
func (t CycleStatus) ToString() string {
|
||||
switch t {
|
||||
case CycleStatusDefault:
|
||||
return "default"
|
||||
case CycleStatusNormal:
|
||||
return "normal"
|
||||
case CycleStatusInvalid:
|
||||
return "invalid"
|
||||
case CycleStatusHidden:
|
||||
return "hidden"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// formatTimestamp 格式化毫秒级时间戳为 DateTime 格式
|
||||
func formatTimestamp(ts string) string {
|
||||
if ts == "" {
|
||||
return ""
|
||||
}
|
||||
millis, err := strconv.ParseInt(ts, 10, 64)
|
||||
if err != nil {
|
||||
return ts
|
||||
}
|
||||
t := time.UnixMilli(millis)
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
// ToResp converts Cycle to RespCycle
|
||||
func (c *Cycle) ToResp() *RespCycle {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
resp := &RespCycle{
|
||||
ID: c.ID,
|
||||
CreateTime: formatTimestamp(c.CreateTime),
|
||||
UpdateTime: formatTimestamp(c.UpdateTime),
|
||||
TenantCycleID: c.TenantCycleID,
|
||||
Owner: *c.Owner.ToResp(),
|
||||
StartTime: formatTimestamp(c.StartTime),
|
||||
EndTime: formatTimestamp(c.EndTime),
|
||||
Score: c.Score,
|
||||
}
|
||||
if c.CycleStatus != nil {
|
||||
s := c.CycleStatus.ToString()
|
||||
resp.CycleStatus = &s
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// ToResp converts KeyResult to RespKeyResult
|
||||
func (k *KeyResult) ToResp() *RespKeyResult {
|
||||
if k == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespKeyResult{
|
||||
ID: k.ID,
|
||||
CreateTime: formatTimestamp(k.CreateTime),
|
||||
UpdateTime: formatTimestamp(k.UpdateTime),
|
||||
Owner: *k.Owner.ToResp(),
|
||||
ObjectiveID: k.ObjectiveID,
|
||||
Position: k.Position,
|
||||
Score: k.Score,
|
||||
Weight: k.Weight,
|
||||
}
|
||||
if k.Deadline != nil {
|
||||
d := formatTimestamp(*k.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
// Serialize ContentBlock to JSON string (only if Content is not nil and has blocks)
|
||||
if k.Content != nil && len(k.Content.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(k.Content); err == nil {
|
||||
s := string(bytes)
|
||||
result.Content = &s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToResp converts Objective to RespObjective
|
||||
func (o *Objective) ToResp() *RespObjective {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
result := &RespObjective{
|
||||
ID: o.ID,
|
||||
CreateTime: formatTimestamp(o.CreateTime),
|
||||
UpdateTime: formatTimestamp(o.UpdateTime),
|
||||
Owner: *o.Owner.ToResp(),
|
||||
CycleID: o.CycleID,
|
||||
Position: o.Position,
|
||||
Score: o.Score,
|
||||
Weight: o.Weight,
|
||||
CategoryID: o.CategoryID,
|
||||
}
|
||||
if o.Deadline != nil {
|
||||
d := formatTimestamp(*o.Deadline)
|
||||
result.Deadline = &d
|
||||
}
|
||||
// Serialize Content to JSON string
|
||||
if o.Content != nil && len(o.Content.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(o.Content); err == nil {
|
||||
s := string(bytes)
|
||||
result.Content = &s
|
||||
}
|
||||
}
|
||||
// Serialize Notes to JSON string
|
||||
if o.Notes != nil && len(o.Notes.Blocks) > 0 {
|
||||
if bytes, err := json.Marshal(o.Notes); err == nil {
|
||||
s := string(bytes)
|
||||
result.Notes = &s
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ToResp converts Owner to RespOwner
|
||||
func (o *Owner) ToResp() *RespOwner {
|
||||
if o == nil {
|
||||
return nil
|
||||
}
|
||||
return &RespOwner{
|
||||
OwnerType: string(o.OwnerType),
|
||||
UserID: o.UserID,
|
||||
}
|
||||
}
|
||||
|
||||
// ptrStr dereferences a string pointer, returning "" for nil.
|
||||
func ptrStr(p *string) string {
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// ptrFloat64 dereferences a float64 pointer, returning 0 for nil.
|
||||
func ptrFloat64(p *float64) float64 {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
142
shortcuts/okr/okr_openapi_test.go
Normal file
142
shortcuts/okr/okr_openapi_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestFormatTimestamp(t *testing.T) {
|
||||
convey.Convey("formatTimestamp", t, func() {
|
||||
convey.Convey("empty string returns empty", func() {
|
||||
result := formatTimestamp("")
|
||||
convey.So(result, convey.ShouldEqual, "")
|
||||
})
|
||||
|
||||
convey.Convey("valid timestamp formats correctly", func() {
|
||||
result := formatTimestamp("1735689600000")
|
||||
// 不检查具体的时分秒,因为时区不同结果会不同
|
||||
convey.So(result, convey.ShouldStartWith, "2025-01-01")
|
||||
})
|
||||
|
||||
convey.Convey("invalid timestamp returns original", func() {
|
||||
result := formatTimestamp("not-a-number")
|
||||
convey.So(result, convey.ShouldEqual, "not-a-number")
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestToRespMethods(t *testing.T) {
|
||||
convey.Convey("ToResp methods handle nil", t, func() {
|
||||
convey.So((*Cycle)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*KeyResult)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*Objective)(nil).ToResp(), convey.ShouldBeNil)
|
||||
convey.So((*Owner)(nil).ToResp(), convey.ShouldBeNil)
|
||||
})
|
||||
|
||||
convey.Convey("ToResp methods work with valid objects", t, func() {
|
||||
convey.Convey("Cycle", func() {
|
||||
cycle := &Cycle{
|
||||
ID: "cycle-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
TenantCycleID: "tenant-cycle-id",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
StartTime: "1735689600000",
|
||||
EndTime: "1751318400000",
|
||||
CycleStatus: CycleStatusNormal.Ptr(),
|
||||
Score: float64Ptr(0.75),
|
||||
}
|
||||
resp := cycle.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
|
||||
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.75)
|
||||
})
|
||||
|
||||
convey.Convey("Objective", func() {
|
||||
obj := &Objective{
|
||||
ID: "obj-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
CycleID: "cycle-id",
|
||||
Position: int32Ptr(1),
|
||||
Score: float64Ptr(0.8),
|
||||
Weight: float64Ptr(1.0),
|
||||
Deadline: strPtr("1751318400000"),
|
||||
Content: &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Test objective"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
resp := obj.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "obj-id")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.8)
|
||||
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
|
||||
convey.Convey("KeyResult", func() {
|
||||
kr := &KeyResult{
|
||||
ID: "kr-id",
|
||||
CreateTime: "1735689600000",
|
||||
UpdateTime: "1735776000000",
|
||||
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou-1")},
|
||||
ObjectiveID: "obj-id",
|
||||
Position: int32Ptr(1),
|
||||
Content: &ContentBlock{
|
||||
Blocks: []ContentBlockElement{
|
||||
{
|
||||
BlockElementType: BlockElementTypeParagraph.Ptr(),
|
||||
Paragraph: &ContentParagraph{
|
||||
Elements: []ContentParagraphElement{
|
||||
{
|
||||
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
|
||||
TextRun: &ContentTextRun{
|
||||
Text: strPtr("Test KR"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Score: float64Ptr(0.9),
|
||||
Weight: float64Ptr(0.5),
|
||||
Deadline: strPtr("1751318400000"),
|
||||
}
|
||||
resp := kr.ToResp()
|
||||
convey.So(resp, convey.ShouldNotBeNil)
|
||||
convey.So(resp.ID, convey.ShouldEqual, "kr-id")
|
||||
convey.So(resp.ObjectiveID, convey.ShouldEqual, "obj-id")
|
||||
convey.So(*resp.Score, convey.ShouldEqual, 0.9)
|
||||
convey.So(*resp.Content, convey.ShouldNotBeEmpty)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 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 }
|
||||
16
shortcuts/okr/shortcuts.go
Normal file
16
shortcuts/okr/shortcuts.go
Normal file
@@ -0,0 +1,16 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// Shortcuts returns all okr shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
OKRListCycles,
|
||||
OKRCycleDetail,
|
||||
}
|
||||
}
|
||||
17
shortcuts/okr/shortcuts_test.go
Normal file
17
shortcuts/okr/shortcuts_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/smartystreets/goconvey/convey"
|
||||
)
|
||||
|
||||
func TestShortcutsRegistration(t *testing.T) {
|
||||
convey.Convey("Shortcuts() returns all commands", t, func() {
|
||||
list := Shortcuts()
|
||||
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
|
||||
})
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
package shortcuts
|
||||
|
||||
import (
|
||||
"github.com/larksuite/cli/shortcuts/okr"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -45,6 +46,7 @@ func init() {
|
||||
allShortcuts = append(allShortcuts, vc.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, wiki.Shortcuts()...)
|
||||
allShortcuts = append(allShortcuts, okr.Shortcuts()...)
|
||||
}
|
||||
|
||||
// AllShortcuts returns a copy of all registered shortcuts (for dump-shortcuts).
|
||||
|
||||
122
skills/lark-okr/SKILL.md
Normal file
122
skills/lark-okr/SKILL.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
name: lark-okr
|
||||
version: 1.0.0
|
||||
description: "飞书 OKR:管理目标与关键结果。查看和编辑 OKR 周期、目标(Objective)、关键结果(Key Result)、对齐关系、量化指标。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: [ "lark-cli" ]
|
||||
cliHelp: "lark-cli okr --help"
|
||||
---
|
||||
|
||||
# okr (v2)
|
||||
|
||||
**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**
|
||||
|
||||
## Shortcuts(推荐优先使用)
|
||||
|
||||
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 中所有目标和关键结果的内容 |
|
||||
|
||||
## 格式说明
|
||||
|
||||
- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Notes 字段使用的富文本格式说明
|
||||
- [`OKR 业务实体`](references/lark-okr-entities.md) 获取 OKR 实体结构,定义和关系,帮助你更好的使用 OKR 功能
|
||||
- **强烈建议** 在操作 OKR 前,阅读[`OKR 业务实体`](references/lark-okr-entities.md)以了解基础概念
|
||||
|
||||
## API Resources
|
||||
|
||||
```bash
|
||||
lark-cli schema okr.<resource>.<method> # 调用 API 前必须先查看参数结构
|
||||
lark-cli okr <resource> <method> [flags] # 调用 API
|
||||
```
|
||||
|
||||
> **重要**:使用原生 API 时,**必须**先运行 `schema` 查看 `--data` / `--params` 参数结构,**不要**猜测字段格式!
|
||||
|
||||
### alignments
|
||||
|
||||
- `delete` — 删除对齐关系
|
||||
- `get` — 获取对齐关系
|
||||
|
||||
### categories
|
||||
|
||||
- `list` — 批量获取分类
|
||||
|
||||
### cycles
|
||||
|
||||
- `list` — 批量获取用户周期
|
||||
- `objectives_position` — 更新用户周期下全部目标的位置
|
||||
- `objectives_weight` — 更新用户周期下全部目标的权重
|
||||
|
||||
### cycle.objectives
|
||||
|
||||
- `create` — 创建目标
|
||||
- `list` — 批量获取用户周期下的目标
|
||||
|
||||
### indicators
|
||||
|
||||
- `patch` — 更新量化指标
|
||||
|
||||
### key_results
|
||||
|
||||
- `delete` — 删除关键结果
|
||||
- `get` — 获取关键结果
|
||||
- `patch` — 更新关键结果
|
||||
|
||||
### key_result.indicators
|
||||
|
||||
- `list` — 获取关键结果的量化指标
|
||||
|
||||
### objectives
|
||||
|
||||
- `delete` — 删除目标
|
||||
- `get` — 获取目标
|
||||
- `key_results_position` — 更新全部关键结果的位置
|
||||
- `key_results_weight` — 更新全部关键结果的权重
|
||||
- `patch` — 更新目标
|
||||
|
||||
### objective.alignments
|
||||
|
||||
- `create` — 创建对齐关系
|
||||
- `list` — 批量获取目标下的对齐关系
|
||||
|
||||
### objective.indicators
|
||||
|
||||
- `list` — 获取目标的量化指标
|
||||
|
||||
### objective.key_results
|
||||
|
||||
- `create` — 创建关键结果
|
||||
- `list` — 批量获取目标下的关键结果
|
||||
|
||||
## 权限表
|
||||
|
||||
| 方法 | 所需 scope |
|
||||
|-----------------------------------|-----------------------------|
|
||||
| `alignments.delete` | `okr:okr.content:writeonly` |
|
||||
| `alignments.get` | `okr:okr.content:readonly` |
|
||||
| `categories.list` | `okr:okr.setting:read` |
|
||||
| `cycles.list` | `okr:okr.period:readonly` |
|
||||
| `cycles.objectives_position` | `okr:okr.content:writeonly` |
|
||||
| `cycles.objectives_weight` | `okr:okr.content:writeonly` |
|
||||
| `cycle.objectives.create` | `okr:okr.content:writeonly` |
|
||||
| `cycle.objectives.list` | `okr:okr.content:readonly` |
|
||||
| `indicators.patch` | `okr:okr.content:writeonly` |
|
||||
| `key_results.delete` | `okr:okr.content:writeonly` |
|
||||
| `key_results.get` | `okr:okr.content:readonly` |
|
||||
| `key_results.patch` | `okr:okr.content:writeonly` |
|
||||
| `key_result.indicators.list` | `okr:okr.content:readonly` |
|
||||
| `objectives.delete` | `okr:okr.content:writeonly` |
|
||||
| `objectives.get` | `okr:okr.content:readonly` |
|
||||
| `objectives.key_results_position` | `okr:okr.content:writeonly` |
|
||||
| `objectives.key_results_weight` | `okr:okr.content:writeonly` |
|
||||
| `objectives.patch` | `okr:okr.content:writeonly` |
|
||||
| `objective.alignments.create` | `okr:okr.content:writeonly` |
|
||||
| `objective.alignments.list` | `okr:okr.content:readonly` |
|
||||
| `objective.indicators.list` | `okr:okr.content:readonly` |
|
||||
| `objective.key_results.create` | `okr:okr.content:writeonly` |
|
||||
| `objective.key_results.list` | `okr:okr.content:readonly` |
|
||||
|
||||
313
skills/lark-okr/references/lark-okr-contentblock.md
Normal file
313
skills/lark-okr/references/lark-okr-contentblock.md
Normal file
@@ -0,0 +1,313 @@
|
||||
# OKR ContentBlock 富文本格式
|
||||
|
||||
OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock` 富文本格式。本文档描述其结构和使用方式。
|
||||
|
||||
## ContentBlock 结构概览
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"style": {
|
||||
"list": {
|
||||
"list_type": "bullet",
|
||||
"indent_level": 0,
|
||||
"number": 1
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "Hello World",
|
||||
"style": {
|
||||
"bold": true,
|
||||
"strike_through": false,
|
||||
"back_color": {
|
||||
"red": 255,
|
||||
"green": 0,
|
||||
"blue": 0,
|
||||
"alpha": 1
|
||||
},
|
||||
"text_color": {
|
||||
"red": 0,
|
||||
"green": 255,
|
||||
"blue": 0,
|
||||
"alpha": 1
|
||||
},
|
||||
"link": {
|
||||
"url": "https://example.com"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph_element_type": "docsLink",
|
||||
"docs_link": {
|
||||
"url": "https://larkoffice.com/docx/xxx",
|
||||
"title": "Lark Document"
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph_element_type": "mention",
|
||||
"mention": {
|
||||
"user_id": "ou_xxx"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"block_element_type": "gallery",
|
||||
"gallery": {
|
||||
"images": [
|
||||
{
|
||||
"file_token": "file_xxx",
|
||||
"src": "https://...",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 类型定义
|
||||
|
||||
### ContentBlock
|
||||
|
||||
根级别内容块。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------|-------------------------|---------|
|
||||
| `blocks` | `ContentBlockElement[]` | 内容块元素数组 |
|
||||
|
||||
### ContentBlockElement
|
||||
|
||||
内容块元素,支持段落或图库。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------------------|--------------------|--------------------------------------------|
|
||||
| `block_element_type` | `BlockElementType` | 块类型:`paragraph` \| `gallery` |
|
||||
| `paragraph` | `ContentParagraph` | 段落内容(当 `block_element_type="paragraph"` 时) |
|
||||
| `gallery` | `ContentGallery` | 图库内容(当 `block_element_type="gallery"` 时) |
|
||||
|
||||
### ContentParagraph
|
||||
|
||||
段落内容。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------------|-----------------------------|-------------|
|
||||
| `style` | `ContentParagraphStyle` | 段落样式(列表类型等) |
|
||||
| `elements` | `ContentParagraphElement[]` | 段落内元素数组 |
|
||||
|
||||
### ContentParagraphElement
|
||||
|
||||
段落内元素,支持文本、文档链接、提及。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------------------------|------------------------|-------------------------------------------|
|
||||
| `paragraph_element_type` | `ParagraphElementType` | 元素类型:`textRun` \| `docsLink` \| `mention` |
|
||||
| `text_run` | `ContentTextRun` | 文本内容 |
|
||||
| `docs_link` | `ContentDocsLink` | 飞书文档链接 |
|
||||
| `mention` | `ContentMention` | 用户提及 |
|
||||
|
||||
### ContentTextRun
|
||||
|
||||
文本块。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------|--------------------|------|
|
||||
| `text` | `string` | 文本内容 |
|
||||
| `style` | `ContentTextStyle` | 文本样式 |
|
||||
|
||||
### ContentTextStyle
|
||||
|
||||
文本样式。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------------------|----------------|-------|
|
||||
| `bold` | `boolean` | 是否粗体 |
|
||||
| `strike_through` | `boolean` | 是否删除线 |
|
||||
| `back_color` | `ContentColor` | 背景颜色 |
|
||||
| `text_color` | `ContentColor` | 文字颜色 |
|
||||
| `link` | `ContentLink` | 链接 |
|
||||
|
||||
### ContentColor
|
||||
|
||||
颜色。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------|-----------|--------------|
|
||||
| `red` | `int32` | 红色通道 (0-255) |
|
||||
| `green` | `int32` | 绿色通道 (0-255) |
|
||||
| `blue` | `int32` | 蓝色通道 (0-255) |
|
||||
| `alpha` | `float64` | 透明度 (0-1) |
|
||||
|
||||
### ContentParagraphStyle
|
||||
|
||||
段落样式。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------|---------------|------|
|
||||
| `list` | `ContentList` | 列表样式 |
|
||||
|
||||
### ContentList
|
||||
|
||||
列表样式。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------------|------------|---------------------------------------------------------------------|
|
||||
| `list_type` | `ListType` | 列表类型:`bullet` \| `number` \| `checkBox` \| `checkedBox` \| `indent` |
|
||||
| `indent_level` | `int32` | 缩进层级 |
|
||||
| `number` | `int32` | 序号(当 `list_type="number"` 时) |
|
||||
|
||||
### ContentGallery
|
||||
|
||||
图库。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|----------|----------------------|-------|
|
||||
| `images` | `ContentImageItem[]` | 图片项数组 |
|
||||
|
||||
### ContentImageItem
|
||||
|
||||
图片项。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|--------------|-----------|----------|
|
||||
| `file_token` | `string` | 文件 token |
|
||||
| `src` | `string` | 图片 URL |
|
||||
| `width` | `float64` | 宽度 |
|
||||
| `height` | `float64` | 高度 |
|
||||
|
||||
### ContentDocsLink
|
||||
|
||||
飞书文档链接。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---------|----------|--------|
|
||||
| `url` | `string` | 链接 URL |
|
||||
| `title` | `string` | 链接标题 |
|
||||
|
||||
### ContentMention
|
||||
|
||||
提及。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-----------|----------|-------|
|
||||
| `user_id` | `string` | 用户 ID |
|
||||
|
||||
### ContentLink
|
||||
|
||||
链接。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|-------|----------|--------|
|
||||
| `url` | `string` | 链接 URL |
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 示例 1:简单文本段落
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "提升用户满意度"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 2:带格式的文本段落
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "Q2 目标",
|
||||
"style": {
|
||||
"bold": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": " - 提升产品质量"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 示例 3:带列表的段落
|
||||
|
||||
```json
|
||||
{
|
||||
"blocks": [
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"style": {
|
||||
"list": {
|
||||
"list_type": "bullet",
|
||||
"indent_level": 0
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "完成功能开发"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"block_element_type": "paragraph",
|
||||
"paragraph": {
|
||||
"style": {
|
||||
"list": {
|
||||
"list_type": "bullet",
|
||||
"indent_level": 0
|
||||
}
|
||||
},
|
||||
"elements": [
|
||||
{
|
||||
"paragraph_element_type": "textRun",
|
||||
"text_run": {
|
||||
"text": "进行用户测试"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
83
skills/lark-okr/references/lark-okr-cycle-detail.md
Normal file
83
skills/lark-okr/references/lark-okr-cycle-detail.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# okr +cycle-detail
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出指定 OKR 周期下的所有目标及其关键结果。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 列出指定周期的目标和关键结果
|
||||
lark-cli okr +cycle-detail --cycle-id 1234567890123456789
|
||||
|
||||
# 预览 API 调用而不实际执行
|
||||
lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run
|
||||
```
|
||||
|
||||
## 参数
|
||||
|
||||
| 参数 | 必填 | 默认值 | 说明 |
|
||||
|-------------------------|----|--------|-----------------------------------------|
|
||||
| `--cycle-id <id>` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 |
|
||||
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 |
|
||||
| `--format <fmt>` | 否 | `json` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 使用 `lark-cli okr +cycle-list` 获取 OKR 周期 ID。
|
||||
2. 执行 `lark-cli okr +cycle-detail --cycle-id "123456"`。
|
||||
3. 报告结果:找到的目标数量、每个目标的 ID、分数、权重及其关键结果。
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"cycle_id": "1234567890123456789",
|
||||
"objectives": [
|
||||
{
|
||||
"id": "2345678901234567890",
|
||||
"create_time": "2025-01-01 00:00:00",
|
||||
"update_time": "2025-01-15 12:00:00",
|
||||
"owner": {
|
||||
"owner_type": "user",
|
||||
"user_id": "ou_xxx"
|
||||
},
|
||||
"cycle_id": "1234567890123456789",
|
||||
"position": 0,
|
||||
"score": 0.75,
|
||||
"weight": 1.0,
|
||||
"deadline": "2025-06-30 23:59:59",
|
||||
"category_id": "cat_456",
|
||||
"content": "{...}",
|
||||
"notes": "{...}",
|
||||
"key_results": [
|
||||
{
|
||||
"id": "3456789012345678901",
|
||||
"create_time": "2025-01-01 00:00:00",
|
||||
"update_time": "2025-01-15 12:00:00",
|
||||
"owner": {
|
||||
"owner_type": "user",
|
||||
"user_id": "ou_xxx"
|
||||
},
|
||||
"objective_id": "2345678901234567890",
|
||||
"position": 0,
|
||||
"score": 0.8,
|
||||
"weight": 0.5,
|
||||
"deadline": "2025-06-30 23:59:59",
|
||||
"content": "{...}"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
其中,content 和 notes 字段是 JSON 字符串,为 OKR ContentBlock 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。
|
||||
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口)
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
87
skills/lark-okr/references/lark-okr-cycle-list.md
Normal file
87
skills/lark-okr/references/lark-okr-cycle-list.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# okr +cycle-list
|
||||
|
||||
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
列出指定用户的 OKR 周期,支持可选的时间范围过滤。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
# 列出用户的所有周期
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx"
|
||||
|
||||
# 使用特定的用户 ID 类型列出周期
|
||||
lark-cli okr +cycle-list --user-id "xxx" --user-id-type user_id
|
||||
|
||||
# 列出时间范围内的周期(例如 2025-01 到 2025-06)
|
||||
lark-cli okr +cycle-list --user-id "ou_xxx" --time-range "2025-01--2025-06"
|
||||
|
||||
# 预览 API 调用而不实际执行
|
||||
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` | 输出格式。 |
|
||||
|
||||
## 工作流程
|
||||
|
||||
1. 获取目标用户的 `open_id`(或其他 ID 类型)。如果用户说"我的 OKR 周期",先通过 `lark-cli contact +get-user` 获取当前用户的
|
||||
ID。
|
||||
2. 执行 `lark-cli okr +cycle-list --user-id "ou_xxx"`,可选择使用 `--time-range`。
|
||||
3. 报告结果:找到的周期数量、每个周期的 ID、开始/结束时间和状态。
|
||||
|
||||
## 输出
|
||||
|
||||
返回 JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"cycles": [
|
||||
{
|
||||
"id": "1234567890123456789",
|
||||
"create_time": "2025-01-01 00:00:00",
|
||||
"update_time": "2025-01-01 00:00:00",
|
||||
"tenant_cycle_id": "789",
|
||||
"owner": {
|
||||
"owner_type": "user",
|
||||
"user_id": "ou_xxx"
|
||||
},
|
||||
"start_time": "2025-01-01 00:00:00",
|
||||
"end_time": "2025-06-30 00:00:00",
|
||||
"cycle_status": "normal",
|
||||
"score": 0
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
```
|
||||
|
||||
在这个周期信息中,这些字段值得关注:
|
||||
- `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 年” 的年度周期
|
||||
- `cycle_status` 为周期状态值,参见下文。
|
||||
|
||||
### 周期状态值
|
||||
|
||||
| 值 | 说明 |
|
||||
|-----------|----------|
|
||||
| `default` | 默认状态 (0) |
|
||||
| `normal` | 生效 (1) |
|
||||
| `invalid` | 失效 (2) |
|
||||
| `hidden` | 隐藏 (3) |
|
||||
|
||||
在 OKR 系统中,default/normal 状态下的周期当前正常生效,invalid 状态下的周期已失效但通常仍然可以填写,hidden 状态下的周期隐藏不可见。
|
||||
|
||||
## 参考
|
||||
|
||||
- [lark-okr](../SKILL.md) -- 所有 OKR 命令
|
||||
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
|
||||
270
skills/lark-okr/references/lark-okr-entities.md
Normal file
270
skills/lark-okr/references/lark-okr-entities.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# OKR 实体定义
|
||||
|
||||
本文档描述飞书 OKR API (`/open-apis/okr/v2`) 中涉及的核心实体及其字段定义。
|
||||
|
||||
## 实体关系概览
|
||||
|
||||
```
|
||||
Cycle (用户周期)
|
||||
└── Objective (目标)
|
||||
├── KeyResult (关键结果)
|
||||
│ └── Indicator (指标)
|
||||
└── Indicator (指标)
|
||||
|
||||
Alignment (对齐关系): Objective ↔ Objective
|
||||
Category (分类): Objective 的分组标签
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Owner (所有者)
|
||||
|
||||
所有者标识 OKR 实体的归属,目前仅支持用户类型。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|--------------|----------|----|-----------------------------------------------|
|
||||
| `owner_type` | `string` | 是 | 所有者类型,通常为 `"user"`。 |
|
||||
| `user_id` | `string` | 否 | 员工 ID,类型由请求参数 `user_id_type` 决定(默认 `open_id`) |
|
||||
|
||||
---
|
||||
|
||||
## Cycle (用户周期)
|
||||
|
||||
用户周期是 OKR 的顶层容器,代表一个时间段内的所有目标与关键结果。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-------------------|-----------|----|--------------------------------------------|
|
||||
| `id` | `string` | 是 | 用户周期 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间 |
|
||||
| `update_time` | `string` | 是 | 更新时间 |
|
||||
| `tenant_cycle_id` | `string` | 是 | 租户周期 ID(同一周期在不同用户下有不同的用户周期 ID,但租户周期 ID 相同) |
|
||||
| `owner` | `Owner` | 是 | 所有者 |
|
||||
| `start_time` | `string` | 是 | 周期开始时间。总是从某月1日开始 |
|
||||
| `end_time` | `string` | 是 | 周期结束时间。到某月最后一日结束 |
|
||||
| `cycle_status` | `integer` | 否 | 周期状态,见下表 |
|
||||
| `score` | `number` | 否 | 周期分数,范围 [0, 1],支持一位小数 |
|
||||
|
||||
### 常用术语
|
||||
|
||||
- **当前周期**: 指周期的 start_time/end_time 所在的时间段与当前时间重叠的周期。如果有多个符合这一标准的周期,通常可以选择周期状态为default/normal的周期,其中较新的一个。当用户提及“上一个周期”,“下一个周期”一类的表述时,通常是以当前周期为准计算。
|
||||
- **所有者**: 绝大多数所有者都是用户,少部分租户启用了“团队OKR”功能,所有者可能是部门。用户身份下,只能编辑所有者为当前用户的
|
||||
OKR。
|
||||
|
||||
### 周期状态 (cycle_status)
|
||||
|
||||
| 值 | 常量名 | 说明 |
|
||||
|---|-----------|-------------|
|
||||
| 0 | `default` | 默认状态 |
|
||||
| 1 | `normal` | 生效中 |
|
||||
| 2 | `invalid` | 已失效(通常仍可填写) |
|
||||
| 3 | `hidden` | 已隐藏(不可见) |
|
||||
|
||||
> **SHORTCUT:** `okr +cycle-list` [lark-okr-cycle-list.md](lark-okr-cycle-list.md) 获取用户的周期列表,可按时间筛选
|
||||
>
|
||||
> **API:** `cycles.list`
|
||||
|
||||
---
|
||||
|
||||
## Objective (目标)
|
||||
|
||||
目标是 OKR 中的 "O",属于某个用户周期,可包含多个关键结果。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---------------|----------------|----|---------------------------------------------------------|
|
||||
| `id` | `string` | 是 | 目标 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间,毫秒时间戳,shortcut 会将其解析为日期时间 |
|
||||
| `update_time` | `string` | 是 | 更新时间,毫秒时间戳,shortcut 会将其解析为日期时间 |
|
||||
| `owner` | `Owner` | 是 | 所有者 |
|
||||
| `cycle_id` | `string` | 是 | 所属用户周期 ID |
|
||||
| `position` | `integer` | 是 | 排序序号,从 1 开始,范围 [1, 100] |
|
||||
| `content` | `ContentBlock` | 否 | 目标内容(富文本),见 [ContentBlock 定义](lark-okr-contentblock.md) |
|
||||
| `score` | `number` | 否 | 目标分数,范围 [0, 1],支持一位小数 |
|
||||
| `notes` | `ContentBlock` | 否 | 目标备注(富文本),见 [ContentBlock 定义](lark-okr-contentblock.md) |
|
||||
| `weight` | `number` | 否 | 目标权重,范围 [0, 1],支持三位小数 |
|
||||
| `deadline` | `string` | 否 | 截止时间,毫秒时间戳,shortcut 会将其解析为日期时间 |
|
||||
| `category_id` | `string` | 否 | 所属分类 ID |
|
||||
|
||||
> **SHORTCUT:**
|
||||
> - `okr +cycle-detail` [lark-okr-cycle-detail.md](lark-okr-cycle-detail.md) 获取某个用户周期下的全部目标和关键结果。时间相关的字段会以日期时间格式解析
|
||||
>
|
||||
> **API:**
|
||||
> - `cycle.objectives.list` — 获取周期下的目标列表
|
||||
> - `objectives.get` — 获取单个目标
|
||||
> - `cycle.objectives.create` — 创建目标
|
||||
> - `objectives.delete` — 删除目标
|
||||
> - `cycles.objectives_position` — 更新周期下的目标排序
|
||||
> - `cycles.objectives_weight` — 更新周期下的目标权重
|
||||
|
||||
---
|
||||
|
||||
## KeyResult (关键结果)
|
||||
|
||||
关键结果是 OKR 中的 "KR",属于某个目标,描述目标的可衡量成果。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|----------------|----------------|----|-----------------------------------------------------------|
|
||||
| `id` | `string` | 是 | 关键结果 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间,毫秒时间戳 |
|
||||
| `update_time` | `string` | 是 | 修改时间,毫秒时间戳 |
|
||||
| `owner` | `Owner` | 是 | 所有者 |
|
||||
| `objective_id` | `string` | 是 | 所属目标 ID |
|
||||
| `position` | `integer` | 是 | 排序序号,从 1 开始,范围 [1, 100] |
|
||||
| `content` | `ContentBlock` | 否 | 关键结果内容(富文本),见 [ContentBlock 定义](lark-okr-contentblock.md) |
|
||||
| `score` | `number` | 否 | 关键结果分数,范围 [0, 1],支持一位小数 |
|
||||
| `weight` | `number` | 否 | 权重,范围 [0, 1],支持三位小数 |
|
||||
| `deadline` | `string` | 否 | 截止时间,毫秒时间戳 |
|
||||
|
||||
> **API:**
|
||||
> - `objective.key_results.list` — 获取目标下的关键结果列表
|
||||
> - `key_results.get` — 获取单个关键结果
|
||||
> - `key_results.patch` — 更新关键结果
|
||||
> - `key_results.delete` — 删除关键结果
|
||||
> - `objectives.key_results_position` — 更新目标下的关键结果排序
|
||||
> - `objectives.key_results_weight` — 更新目标下的关键结果权重
|
||||
|
||||
---
|
||||
|
||||
## Indicator (指标)
|
||||
|
||||
指标是目标和关键结果的量化度量,可独立挂载在 Objective 或 KeyResult 上。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|--------------------------------|-----------------|----|------------------------------------|
|
||||
| `id` | `string` | 是 | 指标 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间,毫秒时间戳 |
|
||||
| `update_time` | `string` | 是 | 更新时间,毫秒时间戳 |
|
||||
| `owner` | `Owner` | 是 | 所有者 |
|
||||
| `entity_type` | `integer` | 是 | 所属实体类型:`2`=目标,`3`=关键结果 |
|
||||
| `entity_id` | `string` | 是 | 所属实体 ID |
|
||||
| `indicator_status` | `integer` | 是 | 指标状态,见下表 |
|
||||
| `status_calculate_type` | `integer` | 是 | 状态计算方式,见下表 |
|
||||
| `start_value` | `number` | 否 | 起始值,范围 [-99999999999, 99999999999] |
|
||||
| `target_value` | `number` | 否 | 目标值,范围 [-99999999999, 99999999999] |
|
||||
| `current_value` | `number` | 否 | 当前值,范围 [-99999999999, 99999999999] |
|
||||
| `current_value_calculate_type` | `integer` | 否 | 当前值计算方式,见下表 |
|
||||
| `unit` | `IndicatorUnit` | 否 | 指标单位 |
|
||||
|
||||
### 修改指南
|
||||
|
||||
- **进度值**: 一般指 `current_value`,单位未提及时通常用百分制计算。
|
||||
- 当用户要求量化的更新 OKR 进度时,一般指的就是修改对应 OKR 的 Indicator。
|
||||
- OKR 在未设置量化指标时,Indicator 的内容为空。如果用户未做特别说明,更新进度时可以默认将进度以百分制设置(初始值0,目标值100,unit 参见下文设置为 0/PERCENT)
|
||||
|
||||
### 指标状态 (indicator_status)
|
||||
|
||||
| 值 | 说明 |
|
||||
|----|-----|
|
||||
| -1 | 未定义 |
|
||||
| 0 | 正常 |
|
||||
| 1 | 有风险 |
|
||||
| 2 | 已延期 |
|
||||
|
||||
### 状态计算方式 (status_calculate_type)
|
||||
|
||||
| 值 | 说明 | 适用范围 |
|
||||
|---|-----------------|---------|
|
||||
| 0 | 手动更新 | 目标、关键结果 |
|
||||
| 1 | 基于进度和当前时间自动更新 | 目标、关键结果 |
|
||||
| 2 | 基于风险最高的关键结果状态更新 | 仅目标 |
|
||||
|
||||
### 当前值计算方式 (current_value_calculate_type)
|
||||
|
||||
| 值 | 说明 | 适用范围 |
|
||||
|---|---------------|---------|
|
||||
| 0 | 手动更新 | 目标、关键结果 |
|
||||
| 1 | 基于关键结果进度自动更新 | 仅目标 |
|
||||
| 2 | 基于拆解的关键结果进度更新 | 仅关键结果 |
|
||||
|
||||
### IndicatorUnit (指标单位)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|--------------|-----------|----|-----------------------------------------------------------------------------|
|
||||
| `unit_type` | `integer` | 是 | 单位类型:`0`=公共,`1`=自定义 |
|
||||
| `unit_value` | `string` | 是 | 单位值。公共类型可选:`PERCENT`(百分比)、`NONE`(无单位)、`YUAN`(元)、`DOLLAR`(美元);自定义类型字符长度不超过 5 |
|
||||
|
||||
> **API:**
|
||||
> - `key_result.indicators.list` — 获取关键结果的指标
|
||||
> - `objective.indicators.list` — 获取目标的指标
|
||||
> - `indicators.patch` — 更新指标
|
||||
|
||||
---
|
||||
|
||||
## Alignment (对齐关系)
|
||||
|
||||
对齐关系描述两个目标之间的上下对齐。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|--------------------|-----------|----|-----------------------|
|
||||
| `id` | `string` | 是 | 对齐 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间,毫秒时间戳 |
|
||||
| `update_time` | `string` | 是 | 更新时间,毫秒时间戳 |
|
||||
| `from_owner` | `Owner` | 是 | 发起对齐的所有者 |
|
||||
| `to_owner` | `Owner` | 是 | 被对齐的所有者 |
|
||||
| `from_entity_type` | `integer` | 是 | 发起对齐的实体类型,固定为 `2`(目标) |
|
||||
| `from_entity_id` | `string` | 是 | 发起对齐的实体 ID |
|
||||
| `to_entity_type` | `integer` | 是 | 被对齐的实体类型,固定为 `2`(目标) |
|
||||
| `to_entity_id` | `string` | 是 | 被对齐的实体 ID |
|
||||
|
||||
> **API:**
|
||||
> - `alignments.get` — 获取对齐关系
|
||||
> - `alignments.delete` — 删除对齐关系
|
||||
> - `objective.alignments.list` — 批量获取目标下的对齐关系
|
||||
> - `objective.alignments.create` — 创建对齐关系
|
||||
|
||||
---
|
||||
|
||||
## Category (分类)
|
||||
|
||||
分类用于对目标进行分组标记(如"个人 OKR"、"团队 OKR"、"承诺 OKR")等。具体的分类根据租户设置而定。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-----------------|----------------|----|-------------------------------------------------------------|
|
||||
| `id` | `string` | 是 | 分类 ID |
|
||||
| `create_time` | `string` | 是 | 创建时间,毫秒时间戳 |
|
||||
| `update_time` | `string` | 是 | 更新时间,毫秒时间戳 |
|
||||
| `category_type` | `string` | 是 | 分类类型:`"person"`=个人,`"team"`=团队 |
|
||||
| `enabled` | `boolean` | 是 | 是否启用 |
|
||||
| `color` | `string` | 是 | 颜色标识:`blue`、`purple`、`wathet`、`turquoise`、`indigo`、`orange` |
|
||||
| `name` | `CategoryName` | 是 | 多语言名称 |
|
||||
|
||||
### CategoryName (分类名称)
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|----------|----|-----|
|
||||
| `zh` | `string` | 否 | 中文名 |
|
||||
| `en` | `string` | 否 | 英文名 |
|
||||
| `ja` | `string` | 否 | 日文名 |
|
||||
|
||||
> **API:** `categories.list` — 批量获取租户设置的分类列表
|
||||
|
||||
---
|
||||
|
||||
## 通用请求参数
|
||||
|
||||
以下参数在多数 OKR API 中通用:
|
||||
|
||||
| 参数 | 位置 | 必填 | 默认值 | 说明 |
|
||||
|----------------------|---------|----|------------------------|--------------------------------------------------|
|
||||
| `user_id_type` | `query` | 否 | `"open_id"` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
|
||||
| `department_id_type` | `query` | 否 | `"open_department_id"` | 部门 ID 类型:`open_department_id` \| `department_id` |
|
||||
| `page_size` | `query` | 否 | `10` | 分页大小,最大 100 |
|
||||
| `page_token` | `query` | 否 | `""` | 分页键,首页传空串 |
|
||||
|
||||
---
|
||||
|
||||
## 权限 Scope 说明
|
||||
|
||||
| Scope | 权限类型 | 说明 |
|
||||
|-----------------------------|------|--------------|
|
||||
| `okr:okr.content:readonly` | 读 | 读取 OKR 内容 |
|
||||
| `okr:okr.content:writeonly` | 写 | 写入/删除 OKR 内容 |
|
||||
| `okr:okr.period:readonly` | 读 | 读取 OKR 周期 |
|
||||
| `okr:okr.setting:read` | 读 | 读取 OKR 设置 |
|
||||
|
||||
所有 OKR API 均支持 `user` 和 `tenant`(应用)两种 access token 类型。
|
||||
|
||||
## 参考
|
||||
|
||||
- [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) — 获取周期下的目标与关键结果
|
||||
43
tests/cli_e2e/okr/okr_cycle_detail_test.go
Normal file
43
tests/cli_e2e/okr/okr_cycle_detail_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// TestOKR_CycleDetailDryRun validates +cycle-detail dry-run output contains the correct method and API path.
|
||||
func TestOKR_CycleDetailDryRun(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", "+cycle-detail",
|
||||
"--cycle-id", "123456",
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain API path with cycle-id, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
|
||||
}
|
||||
|
||||
func setDryRunConfigEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_APP_ID", "cli_dryrun_test")
|
||||
t.Setenv("LARKSUITE_CLI_APP_SECRET", "dryrun_secret")
|
||||
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
|
||||
}
|
||||
59
tests/cli_e2e/okr/okr_cycle_list_test.go
Normal file
59
tests/cli_e2e/okr/okr_cycle_list_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
// 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"
|
||||
)
|
||||
|
||||
// --- Dry-run E2E tests (no real API calls, no secrets needed) ---
|
||||
|
||||
// TestOKR_CycleListDryRun validates +cycle-list dry-run output contains the correct method and API path.
|
||||
func TestOKR_CycleListDryRun(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", "+cycle-list",
|
||||
"--user-id", "ou_dryrun_test",
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles"), "dry-run should contain API path, got: %s", output)
|
||||
assert.True(t, strings.Contains(output, "ou_dryrun_test"), "dry-run should contain user-id, got: %s", output)
|
||||
}
|
||||
|
||||
// TestOKR_CycleListDryRun_WithTimeRange validates +cycle-list dry-run with --time-range flag.
|
||||
func TestOKR_CycleListDryRun_WithTimeRange(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", "+cycle-list",
|
||||
"--user-id", "ou_dryrun_test",
|
||||
"--time-range", "2025-01--2025-06",
|
||||
"--dry-run",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
output := result.Stdout
|
||||
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles"), "dry-run should contain API path, got: %s", output)
|
||||
}
|
||||
Reference in New Issue
Block a user