From d096a5150c5217367299eba717e86a578956dbd6 Mon Sep 17 00:00:00 2001 From: "sunyihong.cpdsss" Date: Wed, 15 Apr 2026 19:56:52 +0800 Subject: [PATCH 1/2] feat: okr domain Change-Id: I1877c56e33e3620b696351ed9e4c8615dbe17c4b --- README.md | 3 +- README.zh.md | 1 + internal/registry/service_descriptions.json | 4 + shortcuts/okr/okr_cli_resp.go | 101 ++++ shortcuts/okr/okr_cycle_detail.go | 182 ++++++ shortcuts/okr/okr_cycle_detail_test.go | 561 ++++++++++++++++++ shortcuts/okr/okr_cycle_list.go | 189 ++++++ shortcuts/okr/okr_cycle_list_test.go | 448 ++++++++++++++ shortcuts/okr/okr_openapi.go | 361 +++++++++++ shortcuts/okr/okr_openapi_test.go | 142 +++++ shortcuts/okr/shortcuts.go | 16 + shortcuts/okr/shortcuts_test.go | 17 + shortcuts/register.go | 2 + skills/lark-okr/SKILL.md | 120 ++++ .../references/lark-okr-contentblock.md | 313 ++++++++++ .../references/lark-okr-cycle-detail.md | 83 +++ .../references/lark-okr-cycle-list.md | 87 +++ tests/cli_e2e/okr/okr_cycle_detail_test.go | 43 ++ tests/cli_e2e/okr/okr_cycle_list_test.go | 59 ++ 19 files changed, 2731 insertions(+), 1 deletion(-) create mode 100644 shortcuts/okr/okr_cli_resp.go create mode 100644 shortcuts/okr/okr_cycle_detail.go create mode 100644 shortcuts/okr/okr_cycle_detail_test.go create mode 100644 shortcuts/okr/okr_cycle_list.go create mode 100644 shortcuts/okr/okr_cycle_list_test.go create mode 100644 shortcuts/okr/okr_openapi.go create mode 100644 shortcuts/okr/okr_openapi_test.go create mode 100644 shortcuts/okr/shortcuts.go create mode 100644 shortcuts/okr/shortcuts_test.go create mode 100644 skills/lark-okr/SKILL.md create mode 100644 skills/lark-okr/references/lark-okr-contentblock.md create mode 100644 skills/lark-okr/references/lark-okr-cycle-detail.md create mode 100644 skills/lark-okr/references/lark-okr-cycle-list.md create mode 100644 tests/cli_e2e/okr/okr_cycle_detail_test.go create mode 100644 tests/cli_e2e/okr/okr_cycle_list_test.go diff --git a/README.md b/README.md index a6b8835cf..7a23eee44 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh.md b/README.zh.md index 07a0d4a1a..5f68b880d 100644 --- a/README.zh.md +++ b/README.zh.md @@ -38,6 +38,7 @@ | 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 | | 🕐 考勤打卡 | 查询个人考勤打卡记录 | | ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 | +| 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐和指标 | ## 安装与快速开始 diff --git a/internal/registry/service_descriptions.json b/internal/registry/service_descriptions.json index 4c0eefc79..14aac95ff 100644 --- a/internal/registry/service_descriptions.json +++ b/internal/registry/service_descriptions.json @@ -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 目标、关键结果、对齐、量化指标" } } } diff --git a/shortcuts/okr/okr_cli_resp.go b/shortcuts/okr/okr_cli_resp.go new file mode 100644 index 000000000..0dba593ba --- /dev/null +++ b/shortcuts/okr/okr_cli_resp.go @@ -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"` +} diff --git a/shortcuts/okr/okr_cycle_detail.go b/shortcuts/okr/okr_cycle_detail.go new file mode 100644 index 000000000..719c22ca4 --- /dev/null +++ b/shortcuts/okr/okr_cycle_detail.go @@ -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 + }, +} diff --git a/shortcuts/okr/okr_cycle_detail_test.go b/shortcuts/okr/okr_cycle_detail_test.go new file mode 100644 index 000000000..edb3b8c1b --- /dev/null +++ b/shortcuts/okr/okr_cycle_detail_test.go @@ -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") + } +} diff --git a/shortcuts/okr/okr_cycle_list.go b/shortcuts/okr/okr_cycle_list.go new file mode 100644 index 000000000..df9e96587 --- /dev/null +++ b/shortcuts/okr/okr_cycle_list.go @@ -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 + }, +} diff --git a/shortcuts/okr/okr_cycle_list_test.go b/shortcuts/okr/okr_cycle_list_test.go new file mode 100644 index 000000000..53b0a5c76 --- /dev/null +++ b/shortcuts/okr/okr_cycle_list_test.go @@ -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") + } +} diff --git a/shortcuts/okr/okr_openapi.go b/shortcuts/okr/okr_openapi.go new file mode 100644 index 000000000..0a4f2f8e0 --- /dev/null +++ b/shortcuts/okr/okr_openapi.go @@ -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 +} diff --git a/shortcuts/okr/okr_openapi_test.go b/shortcuts/okr/okr_openapi_test.go new file mode 100644 index 000000000..a2f156a1b --- /dev/null +++ b/shortcuts/okr/okr_openapi_test.go @@ -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 } diff --git a/shortcuts/okr/shortcuts.go b/shortcuts/okr/shortcuts.go new file mode 100644 index 000000000..afdcd8412 --- /dev/null +++ b/shortcuts/okr/shortcuts.go @@ -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, + } +} diff --git a/shortcuts/okr/shortcuts_test.go b/shortcuts/okr/shortcuts_test.go new file mode 100644 index 000000000..00cb1f486 --- /dev/null +++ b/shortcuts/okr/shortcuts_test.go @@ -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) + }) +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 09d3813b9..f5b085689 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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). diff --git a/skills/lark-okr/SKILL.md b/skills/lark-okr/SKILL.md new file mode 100644 index 000000000..5f26e5288 --- /dev/null +++ b/skills/lark-okr/SKILL.md @@ -0,0 +1,120 @@ +--- +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 + [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 字段使用的富文本格式说明 + +## API Resources + +```bash +lark-cli schema okr.. # 调用 API 前必须先查看参数结构 +lark-cli okr [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` | + diff --git a/skills/lark-okr/references/lark-okr-contentblock.md b/skills/lark-okr/references/lark-okr-contentblock.md new file mode 100644 index 000000000..dbbd3224c --- /dev/null +++ b/skills/lark-okr/references/lark-okr-contentblock.md @@ -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": "进行用户测试" + } + } + ] + } + } + ] +} +``` diff --git a/skills/lark-okr/references/lark-okr-cycle-detail.md b/skills/lark-okr/references/lark-okr-cycle-detail.md new file mode 100644 index 000000000..064d35e0d --- /dev/null +++ b/skills/lark-okr/references/lark-okr-cycle-detail.md @@ -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 ` | 否 | `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) -- 认证和全局参数 diff --git a/skills/lark-okr/references/lark-okr-cycle-list.md b/skills/lark-okr/references/lark-okr-cycle-list.md new file mode 100644 index 000000000..a8bce8dc4 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-cycle-list.md @@ -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) -- 认证和全局参数 diff --git a/tests/cli_e2e/okr/okr_cycle_detail_test.go b/tests/cli_e2e/okr/okr_cycle_detail_test.go new file mode 100644 index 000000000..00949b03a --- /dev/null +++ b/tests/cli_e2e/okr/okr_cycle_detail_test.go @@ -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") +} diff --git a/tests/cli_e2e/okr/okr_cycle_list_test.go b/tests/cli_e2e/okr/okr_cycle_list_test.go new file mode 100644 index 000000000..4787e7414 --- /dev/null +++ b/tests/cli_e2e/okr/okr_cycle_list_test.go @@ -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) +} From 3ff3041c2c51f4072d8ddd20491e7ccab94870c0 Mon Sep 17 00:00:00 2001 From: "sunyihong.cpdsss" Date: Fri, 17 Apr 2026 14:16:59 +0800 Subject: [PATCH 2/2] feat: okr skill update Change-Id: I1877c56e33e3620b696351ed9e4c8615dbe17c4b --- skills/lark-okr/SKILL.md | 2 + .../lark-okr/references/lark-okr-entities.md | 270 ++++++++++++++++++ 2 files changed, 272 insertions(+) create mode 100644 skills/lark-okr/references/lark-okr-entities.md diff --git a/skills/lark-okr/SKILL.md b/skills/lark-okr/SKILL.md index 5f26e5288..be6082850 100644 --- a/skills/lark-okr/SKILL.md +++ b/skills/lark-okr/SKILL.md @@ -24,6 +24,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr + [flags]`) ## 格式说明 - [`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 diff --git a/skills/lark-okr/references/lark-okr-entities.md b/skills/lark-okr/references/lark-okr-entities.md new file mode 100644 index 000000000..2b73038aa --- /dev/null +++ b/skills/lark-okr/references/lark-okr-entities.md @@ -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) — 获取周期下的目标与关键结果