diff --git a/internal/registry/service_descriptions.json b/internal/registry/service_descriptions.json index d401586c4..cc61e7dfc 100644 --- a/internal/registry/service_descriptions.json +++ b/internal/registry/service_descriptions.json @@ -39,6 +39,10 @@ "en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" }, "zh": { "title": "妙记", "description": "妙记信息获取、内容查询" } }, + "okr": { + "en": { "title": "OKR", "description": "OKR objectives, key results, periods, progress, and review management" }, + "zh": { "title": "OKR", "description": "OKR 目标、关键结果、周期、进展、复盘管理" } + }, "sheets": { "en": { "title": "Sheets", "description": "Spreadsheet operations" }, "zh": { "title": "电子表格", "description": "电子表格操作" } diff --git a/shortcuts/okr/okr_get.go b/shortcuts/okr/okr_get.go new file mode 100644 index 000000000..06a947b76 --- /dev/null +++ b/shortcuts/okr/okr_get.go @@ -0,0 +1,150 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/shortcuts/common" +) + +// GetOKR batch-gets OKR details by ID(s). +var GetOKR = common.Shortcut{ + Service: "okr", + Command: "+get", + Description: "get OKR details by ID(s)", + Risk: "read", + Scopes: []string{"okr:okr:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + + Flags: []common.Flag{ + {Name: "okr-ids", Desc: "comma-separated OKR IDs (max 10)", Required: true}, + {Name: "lang", Desc: "language: zh_cn or en_us (default zh_cn)", Default: "zh_cn", Enum: []string{"zh_cn", "en_us"}}, + }, + + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + ids := splitIDs(runtime.Str("okr-ids")) + if len(ids) == 0 { + return fmt.Errorf("--okr-ids is required") + } + if len(ids) > 10 { + return fmt.Errorf("--okr-ids cannot contain more than 10 IDs (got %d)", len(ids)) + } + return nil + }, + + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ids := splitIDs(runtime.Str("okr-ids")) + params := map[string]interface{}{ + "okr_ids": ids, + "user_id_type": "open_id", + "lang": runtime.Str("lang"), + } + return common.NewDryRunAPI(). + GET("/open-apis/okr/v1/okrs/batch_get"). + Params(params) + }, + + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ids := splitIDs(runtime.Str("okr-ids")) + + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", "open_id") + queryParams.Set("lang", runtime.Str("lang")) + for _, id := range ids { + queryParams.Add("okr_ids", id) + } + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/okr/v1/okrs/batch_get", + QueryParams: queryParams, + }) + + var result map[string]interface{} + if err == nil { + if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { + return WrapOkrError(ErrCodeOkrInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse OKR batch get response") + } + } + + data, err := HandleOkrApiResult(result, err, "batch get OKRs") + if err != nil { + return err + } + + okrList, _ := data["okr_list"].([]interface{}) + + outData := map[string]interface{}{ + "okr_list": okrList, + } + + runtime.OutFormat(outData, nil, func(w io.Writer) { + if len(okrList) == 0 { + fmt.Fprintln(w, "No OKRs found for the given IDs.") + return + } + + for i, item := range okrList { + okr, ok := item.(map[string]interface{}) + if !ok { + continue + } + okrID, _ := okr["id"].(string) + name, _ := okr["name"].(string) + periodID, _ := okr["period_id"].(string) + + fmt.Fprintf(w, "[%d] OKR: %s\n", i+1, name) + fmt.Fprintf(w, " OKR ID: %s\n", okrID) + fmt.Fprintf(w, " Period ID: %s\n", periodID) + + objectives, _ := okr["objective_list"].([]interface{}) + for j, obj := range objectives { + objective, ok := obj.(map[string]interface{}) + if !ok { + continue + } + content, _ := objective["content"].(string) + objID, _ := objective["id"].(string) + + progressStr := "" + if pr, ok := objective["progress_rate"].(map[string]interface{}); ok { + progressStr = " " + formatProgressPercent(pr) + } + + fmt.Fprintf(w, "\n O%d: %s%s\n", j+1, content, progressStr) + fmt.Fprintf(w, " Objective ID: %s\n", objID) + + krList, _ := objective["kr_list"].([]interface{}) + for k, kr := range krList { + keyResult, ok := kr.(map[string]interface{}) + if !ok { + continue + } + krContent, _ := keyResult["content"].(string) + krID, _ := keyResult["id"].(string) + + krProgressStr := "" + if pr, ok := keyResult["progress_rate"].(map[string]interface{}); ok { + krProgressStr = " " + formatProgressPercent(pr) + } + + fmt.Fprintf(w, " KR%d: %s%s\n", k+1, krContent, krProgressStr) + fmt.Fprintf(w, " KR ID: %s\n", krID) + } + } + fmt.Fprintln(w) + } + }) + + return nil + }, +} diff --git a/shortcuts/okr/okr_get_test.go b/shortcuts/okr/okr_get_test.go new file mode 100644 index 000000000..be122da09 --- /dev/null +++ b/shortcuts/okr/okr_get_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestGetOKR(t *testing.T) { + tests := []struct { + name string + okrIDs string + formatFlag string + expectedOutput []string + }{ + { + name: "single ID pretty", + okrIDs: "okr-001", + formatFlag: "pretty", + expectedOutput: []string{ + "OKR: Test OKR", + "OKR ID: okr-001", + "O1: Grow the team", + }, + }, + { + name: "single ID json", + okrIDs: "okr-001", + formatFlag: "json", + expectedOutput: []string{ + `"okr_list"`, + `"okr-001"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v1/okrs/batch_get", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "okr_list": []interface{}{ + map[string]interface{}{ + "id": "okr-001", + "name": "Test OKR", + "period_id": "period-001", + "objective_list": []interface{}{ + map[string]interface{}{ + "id": "obj-001", + "content": "Grow the team", + "kr_list": []interface{}{}, + }, + }, + }, + }, + }, + }, + }) + + shortcut := GetOKR + shortcut.AuthTypes = []string{"user", "bot"} + + err := runMountedOkrShortcut(t, shortcut, []string{"+get", "--okr-ids", tt.okrIDs, "--format", tt.formatFlag, "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + for _, expected := range tt.expectedOutput { + if !strings.Contains(out, expected) { + t.Errorf("output missing expected string (%s), got: %s", expected, out) + } + } + }) + } +} + +func TestGetOKRValidation(t *testing.T) { + t.Run("too many IDs", func(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + shortcut := GetOKR + shortcut.AuthTypes = []string{"user", "bot"} + + err := runMountedOkrShortcut(t, shortcut, []string{"+get", "--okr-ids", "1,2,3,4,5,6,7,8,9,10,11", "--as", "bot"}, f, stdout) + if err == nil { + t.Fatal("expected validation error for too many IDs") + } + }) +} diff --git a/shortcuts/okr/okr_list.go b/shortcuts/okr/okr_list.go new file mode 100644 index 000000000..6b6c01163 --- /dev/null +++ b/shortcuts/okr/okr_list.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ListOKR lists OKRs for a user (defaults to current logged-in user). +var ListOKR = common.Shortcut{ + Service: "okr", + Command: "+list", + Description: "list OKRs for a user (defaults to current user)", + Risk: "read", + Scopes: []string{"okr:okr:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + + Flags: []common.Flag{ + {Name: "user-id", Desc: "user open_id (defaults to current logged-in user)"}, + {Name: "period-id", Desc: "OKR period ID (if omitted, auto-detects current period)"}, + {Name: "lang", Desc: "language: zh_cn or en_us (default zh_cn)", Default: "zh_cn", Enum: []string{"zh_cn", "en_us"}}, + {Name: "offset", Type: "int", Default: "0", Desc: "pagination offset"}, + {Name: "limit", Type: "int", Default: "10", Desc: "page size (default 10, max 10)"}, + }, + + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + userID := runtime.Str("user-id") + if userID == "" { + userID = runtime.Config.UserOpenId + } + params := map[string]interface{}{ + "user_id_type": "open_id", + "offset": runtime.Int("offset"), + "limit": runtime.Int("limit"), + "lang": runtime.Str("lang"), + } + if pid := runtime.Str("period-id"); pid != "" { + params["period_ids"] = pid + } + return common.NewDryRunAPI(). + GET("/open-apis/okr/v1/users/" + userID + "/okrs"). + Params(params) + }, + + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + userID := runtime.Str("user-id") + if userID == "" && runtime.Config.UserOpenId == "" { + return fmt.Errorf("--user-id is required (or login first with: lark-cli auth login --domain okr)") + } + limit := runtime.Int("limit") + if limit < 1 { + return fmt.Errorf("--limit must be at least 1") + } + if limit > 10 { + return fmt.Errorf("--limit cannot exceed 10 (API maximum)") + } + return nil + }, + + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + userID := runtime.Str("user-id") + if userID == "" { + userID = runtime.Config.UserOpenId + } + + periodID := runtime.Str("period-id") + + // Auto-detect current period if not specified + if periodID == "" { + pid, err := detectCurrentPeriod(runtime) + if err != nil { + return err + } + periodID = pid + } + + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", "open_id") + queryParams.Set("offset", fmt.Sprintf("%d", runtime.Int("offset"))) + queryParams.Set("limit", fmt.Sprintf("%d", runtime.Int("limit"))) + queryParams.Set("lang", runtime.Str("lang")) + if periodID != "" { + queryParams.Set("period_ids", periodID) + } + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/okr/v1/users/" + userID + "/okrs", + QueryParams: queryParams, + }) + + var result map[string]interface{} + if err == nil { + if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { + return WrapOkrError(ErrCodeOkrInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse OKR list response") + } + } + + data, err := HandleOkrApiResult(result, err, "list OKRs") + if err != nil { + return err + } + + okrList, _ := data["okr_list"].([]interface{}) + total, _ := data["total"] + + outData := map[string]interface{}{ + "okr_list": okrList, + "total": total, + } + + runtime.OutFormat(outData, nil, func(w io.Writer) { + if len(okrList) == 0 { + fmt.Fprintln(w, "No OKRs found for this period.") + return + } + + for i, item := range okrList { + okr, ok := item.(map[string]interface{}) + if !ok { + continue + } + okrID, _ := okr["id"].(string) + name, _ := okr["name"].(string) + + fmt.Fprintf(w, "[%d] OKR: %s\n", i+1, name) + fmt.Fprintf(w, " OKR ID: %s\n", okrID) + + objectives, _ := okr["objective_list"].([]interface{}) + for j, obj := range objectives { + objective, ok := obj.(map[string]interface{}) + if !ok { + continue + } + content, _ := objective["content"].(string) + objID, _ := objective["id"].(string) + + progressStr := "" + if pr, ok := objective["progress_rate"].(map[string]interface{}); ok { + progressStr = " " + formatProgressPercent(pr) + } + + fmt.Fprintf(w, "\n O%d: %s%s\n", j+1, content, progressStr) + fmt.Fprintf(w, " Objective ID: %s\n", objID) + + krList, _ := objective["kr_list"].([]interface{}) + for k, kr := range krList { + keyResult, ok := kr.(map[string]interface{}) + if !ok { + continue + } + krContent, _ := keyResult["content"].(string) + krID, _ := keyResult["id"].(string) + + krProgressStr := "" + if pr, ok := keyResult["progress_rate"].(map[string]interface{}); ok { + krProgressStr = " " + formatProgressPercent(pr) + } + + fmt.Fprintf(w, " KR%d: %s%s\n", k+1, krContent, krProgressStr) + fmt.Fprintf(w, " KR ID: %s\n", krID) + } + } + fmt.Fprintln(w) + } + }) + + return nil + }, +} + +// detectCurrentPeriod fetches periods and returns the current active period ID. +func detectCurrentPeriod(runtime *common.RuntimeContext) (string, error) { + queryParams := make(larkcore.QueryParams) + queryParams.Set("page_size", "20") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/okr/v1/periods", + QueryParams: queryParams, + }) + + var result map[string]interface{} + if err == nil { + if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { + return "", WrapOkrError(ErrCodeOkrInternalError, fmt.Sprintf("failed to parse periods response: %v", parseErr), "detect current period") + } + } + + data, err := HandleOkrApiResult(result, err, "list periods for auto-detection") + if err != nil { + return "", err + } + + items, _ := data["items"].([]interface{}) + current := findCurrentPeriod(items) + if current == nil { + return "", nil + } + + id, _ := current["id"].(string) + return id, nil +} diff --git a/shortcuts/okr/okr_list_test.go b/shortcuts/okr/okr_list_test.go new file mode 100644 index 000000000..d1dada1a6 --- /dev/null +++ b/shortcuts/okr/okr_list_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestListOKR(t *testing.T) { + tests := []struct { + name string + args []string + formatFlag string + expectedOutput []string + }{ + { + name: "pretty format with period", + args: []string{"--period-id", "period-001"}, + formatFlag: "pretty", + expectedOutput: []string{ + "OKR: Test OKR", + "O1: Increase revenue", + "KR1: Achieve $1M ARR", + }, + }, + { + name: "json format with period", + args: []string{"--period-id", "period-001"}, + formatFlag: "json", + expectedOutput: []string{ + `"okr_list"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v1/users/ou_testuser/okrs", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "total": float64(1), + "okr_list": []interface{}{ + map[string]interface{}{ + "id": "okr-001", + "name": "Test OKR", + "objective_list": []interface{}{ + map[string]interface{}{ + "id": "obj-001", + "content": "Increase revenue", + "progress_rate": map[string]interface{}{ + "percent": float64(50), + "status": "0", + }, + "kr_list": []interface{}{ + map[string]interface{}{ + "id": "kr-001", + "content": "Achieve $1M ARR", + "progress_rate": map[string]interface{}{ + "percent": float64(30), + "status": "0", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + + shortcut := ListOKR + shortcut.AuthTypes = []string{"user", "bot"} + + args := append([]string{"+list", "--format", tt.formatFlag, "--as", "bot"}, tt.args...) + err := runMountedOkrShortcut(t, shortcut, args, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + for _, expected := range tt.expectedOutput { + if !strings.Contains(out, expected) { + t.Errorf("output missing expected string (%s), got: %s", expected, out) + } + } + }) + } +} + +func TestListOKREmpty(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v1/users/ou_testuser/okrs", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "total": float64(0), + "okr_list": []interface{}{}, + }, + }, + }) + + shortcut := ListOKR + shortcut.AuthTypes = []string{"user", "bot"} + + err := runMountedOkrShortcut(t, shortcut, []string{"+list", "--period-id", "p1", "--format", "pretty", "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "No OKRs found") { + t.Errorf("expected empty message, got: %s", out) + } +} diff --git a/shortcuts/okr/okr_periods.go b/shortcuts/okr/okr_periods.go new file mode 100644 index 000000000..c8e0ea57f --- /dev/null +++ b/shortcuts/okr/okr_periods.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/shortcuts/common" +) + +// ListPeriods lists OKR periods. +var ListPeriods = common.Shortcut{ + Service: "okr", + Command: "+periods", + Description: "list OKR periods", + Risk: "read", + Scopes: []string{"okr:okr.period:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + + Flags: []common.Flag{ + {Name: "page-token", Desc: "pagination token"}, + {Name: "page-size", Type: "int", Default: "10", Desc: "page size (default 10)"}, + }, + + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{ + "page_size": runtime.Int("page-size"), + } + if pt := runtime.Str("page-token"); pt != "" { + params["page_token"] = pt + } + return common.NewDryRunAPI(). + GET("/open-apis/okr/v1/periods"). + Params(params) + }, + + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + queryParams := make(larkcore.QueryParams) + queryParams.Set("page_size", fmt.Sprintf("%d", runtime.Int("page-size"))) + if pt := runtime.Str("page-token"); pt != "" { + queryParams.Set("page_token", pt) + } + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/okr/v1/periods", + QueryParams: queryParams, + }) + + var result map[string]interface{} + if err == nil { + if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { + return WrapOkrError(ErrCodeOkrInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse periods response") + } + } + + data, err := HandleOkrApiResult(result, err, "list periods") + if err != nil { + return err + } + + items, _ := data["items"].([]interface{}) + pageToken, _ := data["page_token"].(string) + hasMore, _ := data["has_more"].(bool) + + outData := map[string]interface{}{ + "items": items, + "page_token": pageToken, + "has_more": hasMore, + } + + runtime.OutFormat(outData, nil, func(w io.Writer) { + if len(items) == 0 { + fmt.Fprintln(w, "No OKR periods found.") + return + } + + for i, item := range items { + period, ok := item.(map[string]interface{}) + if !ok { + continue + } + id, _ := period["id"].(string) + zhName, _ := period["zh_name"].(string) + enName, _ := period["en_name"].(string) + + name := zhName + if name == "" { + name = enName + } + + startStr, _ := period["period_start_time"].(string) + endStr, _ := period["period_end_time"].(string) + start := formatTimestampMs(startStr) + end := formatTimestampMs(endStr) + + currentTag := "" + if findCurrentPeriod([]interface{}{item}) != nil { + currentTag = " [current]" + } + + fmt.Fprintf(w, "[%d] %s%s\n", i+1, name, currentTag) + fmt.Fprintf(w, " ID: %s\n", id) + if start != "" && end != "" { + fmt.Fprintf(w, " Period: %s ~ %s\n", start, end) + } + fmt.Fprintln(w) + } + + if hasMore && pageToken != "" { + fmt.Fprintf(w, "More periods available. Use --page-token %s to see next page.\n", pageToken) + } + }) + + return nil + }, +} diff --git a/shortcuts/okr/okr_periods_test.go b/shortcuts/okr/okr_periods_test.go new file mode 100644 index 000000000..48ed12592 --- /dev/null +++ b/shortcuts/okr/okr_periods_test.go @@ -0,0 +1,256 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestListPeriods(t *testing.T) { + tests := []struct { + name string + formatFlag string + expectedOutput []string + }{ + { + name: "pretty format", + formatFlag: "pretty", + expectedOutput: []string{ + "2026 Q1", + "ID: period-001", + }, + }, + { + name: "json format", + formatFlag: "json", + expectedOutput: []string{ + `"id"`, + `"period-001"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v1/periods", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "id": "period-001", + "zh_name": "2026 Q1", + "en_name": "2026 Q1", + "status": float64(0), + "period_start_time": "1735689600000", + "period_end_time": "1743465600000", + }, + }, + "page_token": "", + "has_more": false, + }, + }, + }) + + // Override AuthTypes to include bot for testing + shortcut := ListPeriods + shortcut.AuthTypes = []string{"user", "bot"} + + err := runMountedOkrShortcut(t, shortcut, []string{"+periods", "--format", tt.formatFlag, "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + for _, expected := range tt.expectedOutput { + if !strings.Contains(out, expected) { + t.Errorf("output missing expected string (%s), got: %s", expected, out) + } + } + }) + } +} + +func TestListPeriodsEmpty(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v1/periods", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{}, + "page_token": "", + "has_more": false, + }, + }, + }) + + shortcut := ListPeriods + shortcut.AuthTypes = []string{"user", "bot"} + + err := runMountedOkrShortcut(t, shortcut, []string{"+periods", "--format", "pretty", "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "No OKR periods found") { + t.Errorf("expected empty message, got: %s", out) + } +} + +func TestListPeriodsRegistration(t *testing.T) { + s := ListPeriods + if s.Service != "okr" { + t.Errorf("expected service 'okr', got '%s'", s.Service) + } + if s.Command != "+periods" { + t.Errorf("expected command '+periods', got '%s'", s.Command) + } + if s.Risk != "read" { + t.Errorf("expected risk 'read', got '%s'", s.Risk) + } + + // Verify flags exist + flagNames := make(map[string]bool) + for _, flag := range s.Flags { + flagNames[flag.Name] = true + } + for _, required := range []string{"page-token", "page-size"} { + if !flagNames[required] { + t.Errorf("missing flag: %s", required) + } + } + + // Verify shortcut is in Shortcuts() return + shortcuts := Shortcuts() + found := false + for _, sc := range shortcuts { + if sc.Command == "+periods" { + found = true + break + } + } + if !found { + t.Error("ListPeriods not found in Shortcuts()") + } +} + +func TestShortcutsCount(t *testing.T) { + shortcuts := Shortcuts() + if len(shortcuts) != 6 { + t.Errorf("expected 6 shortcuts, got %d", len(shortcuts)) + } + + expectedCommands := map[string]bool{ + "+list": false, + "+get": false, + "+periods": false, + "+progress-add": false, + "+progress-get": false, + "+review": false, + } + for _, s := range shortcuts { + if _, ok := expectedCommands[s.Command]; ok { + expectedCommands[s.Command] = true + } + } + for cmd, found := range expectedCommands { + if !found { + t.Errorf("missing shortcut: %s", cmd) + } + } + + // All shortcuts should have Service == "okr" + for _, s := range shortcuts { + if s.Service != "okr" { + t.Errorf("shortcut %s has service '%s', expected 'okr'", s.Command, s.Service) + } + } +} + +// Verify DryRun function exists on all shortcuts +func TestAllShortcutsHaveDryRun(t *testing.T) { + shortcuts := Shortcuts() + for _, s := range shortcuts { + if s.DryRun == nil { + t.Errorf("shortcut %s has no DryRun function", s.Command) + } + } +} + +// Verify all shortcuts have valid structure +func TestShortcutStructure(t *testing.T) { + for _, s := range Shortcuts() { + if s.Execute == nil { + t.Errorf("shortcut %s has no Execute function", s.Command) + } + if s.Description == "" { + t.Errorf("shortcut %s has no description", s.Command) + } + if len(s.Scopes) == 0 { + t.Errorf("shortcut %s has no scopes", s.Command) + } + if len(s.AuthTypes) == 0 { + t.Errorf("shortcut %s has no auth types", s.Command) + } + // All OKR shortcuts should only support user identity + for _, at := range s.AuthTypes { + if at != "user" { + t.Errorf("shortcut %s has unexpected auth type '%s' (OKR API only supports user)", s.Command, at) + } + } + + // All OKR scopes should start with "okr:" + for _, scope := range s.Scopes { + if !strings.HasPrefix(scope, "okr:") { + t.Errorf("shortcut %s has scope '%s' not starting with 'okr:'", s.Command, scope) + } + } + } +} + +// Ensure DryRun can be called without panic (basic nil safety) +func TestDryRunNilSafety(t *testing.T) { + for _, s := range Shortcuts() { + t.Run(s.Command, func(t *testing.T) { + // DryRun functions should not panic when called with basic RuntimeContext + // This just verifies the functions exist and are callable + if s.DryRun == nil { + t.Skipf("no DryRun for %s", s.Command) + } + }) + } +} + +func TestWriteShortcutsRisk(t *testing.T) { + for _, s := range Shortcuts() { + if s.Command == "+progress-add" { + if s.Risk != "write" { + t.Errorf("shortcut %s should have risk 'write', got '%s'", s.Command, s.Risk) + } + } else { + if s.Risk != "read" { + t.Errorf("shortcut %s should have risk 'read', got '%s'", s.Command, s.Risk) + } + } + } +} + +func init() { + // Ensure common package is used + _ = common.File +} diff --git a/shortcuts/okr/okr_progress_add.go b/shortcuts/okr/okr_progress_add.go new file mode 100644 index 000000000..355124cba --- /dev/null +++ b/shortcuts/okr/okr_progress_add.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/shortcuts/common" +) + +// buildProgressBody constructs the request body for creating a progress record. +// Precedence: --data provides the base, individual flags override it. +func buildProgressBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + body := make(map[string]interface{}) + + // Step 1: --data provides the base payload + if dataStr := runtime.Str("data"); dataStr != "" { + if err := json.Unmarshal([]byte(dataStr), &body); err != nil { + return nil, fmt.Errorf("--data must be a valid JSON object: %v", err) + } + } + + // Step 2: individual flags override --data values + if targetID := runtime.Str("target-id"); targetID != "" { + body["target_id"] = targetID + } + if targetType := runtime.Str("target-type"); targetType != "" { + resolved, err := resolveTargetType(targetType) + if err != nil { + return nil, err + } + body["target_type"] = resolved + } + + if text := runtime.Str("text"); text != "" { + body["content"] = wrapPlainTextContent(text) + } else if contentStr := runtime.Str("content"); contentStr != "" { + var content interface{} + if err := json.Unmarshal([]byte(contentStr), &content); err != nil { + return nil, fmt.Errorf("--content must be a valid JSON object: %v", err) + } + body["content"] = content + } + + if sourceTitle := runtime.Str("source-title"); sourceTitle != "" { + body["source_title"] = sourceTitle + } else if _, ok := body["source_title"]; !ok { + body["source_title"] = "lark-cli" + } + + if sourceURL := runtime.Str("source-url"); sourceURL != "" { + body["source_url"] = sourceURL + } else if _, ok := body["source_url"]; !ok { + body["source_url"] = "https://github.com/larksuite/cli" + } + + return body, nil +} + +// AddProgress adds a progress record to an objective or key result. +var AddProgress = common.Shortcut{ + Service: "okr", + Command: "+progress-add", + Description: "add a progress record to an objective or key result", + Risk: "write", + Scopes: []string{"okr:okr.progress:writeonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + + Flags: []common.Flag{ + {Name: "target-id", Desc: "target objective or key result ID (required unless provided via --data)"}, + {Name: "target-type", Desc: "target type: objective or key_result (required unless provided via --data)", Enum: []string{"objective", "key_result", "2", "3"}}, + {Name: "text", Desc: "plain text progress content (auto-converted to rich text)"}, + {Name: "content", Desc: "rich text JSON content", Input: []string{common.File, common.Stdin}}, + {Name: "source-title", Desc: "source title (default: lark-cli)"}, + {Name: "source-url", Desc: "source URL (default: https://github.com/larksuite/cli)"}, + {Name: "data", Desc: "full JSON payload; individual flags override fields in --data"}, + }, + + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + data := runtime.Str("data") + text := runtime.Str("text") + content := runtime.Str("content") + + // Content is required via --text, --content, or --data + if text == "" && content == "" && data == "" { + return fmt.Errorf("one of --text, --content, or --data is required") + } + + // target-id and target-type are required unless --data provides them + if data == "" { + if runtime.Str("target-id") == "" { + return fmt.Errorf("--target-id is required (or provide via --data)") + } + if runtime.Str("target-type") == "" { + return fmt.Errorf("--target-type is required (or provide via --data)") + } + } + return nil + }, + + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, err := buildProgressBody(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST("/open-apis/okr/v1/progress_records"). + Params(map[string]interface{}{"user_id_type": "open_id"}). + Body(body) + }, + + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body, err := buildProgressBody(runtime) + if err != nil { + return WrapOkrError(ErrCodeOkrInvalidParams, err.Error(), "build progress body") + } + + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", "open_id") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/okr/v1/progress_records", + QueryParams: queryParams, + Body: body, + }) + + var result map[string]interface{} + if err == nil { + if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { + return WrapOkrError(ErrCodeOkrInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse progress response") + } + } + + data, err := HandleOkrApiResult(result, err, "add progress record") + if err != nil { + return err + } + + progressID, _ := data["progress_id"].(string) + + outData := map[string]interface{}{ + "progress_id": progressID, + } + + runtime.OutFormat(outData, nil, func(w io.Writer) { + fmt.Fprintln(w, "Progress record added successfully!") + if progressID != "" { + fmt.Fprintf(w, "Progress ID: %s\n", progressID) + } + }) + return nil + }, +} diff --git a/shortcuts/okr/okr_progress_add_test.go b/shortcuts/okr/okr_progress_add_test.go new file mode 100644 index 000000000..d8cb7bb6c --- /dev/null +++ b/shortcuts/okr/okr_progress_add_test.go @@ -0,0 +1,88 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAddProgress(t *testing.T) { + tests := []struct { + name string + args []string + formatFlag string + expectedOutput []string + }{ + { + name: "add with text pretty", + args: []string{"--target-id", "kr-001", "--target-type", "key_result", "--text", "Completed 80% of development"}, + formatFlag: "pretty", + expectedOutput: []string{ + "Progress record added successfully!", + "Progress ID: prog-001", + }, + }, + { + name: "add with text json", + args: []string{"--target-id", "obj-001", "--target-type", "objective", "--text", "On track"}, + formatFlag: "json", + expectedOutput: []string{ + `"progress_id"`, + `"prog-001"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/progress_records", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "progress_id": "prog-001", + }, + }, + }) + + shortcut := AddProgress + shortcut.AuthTypes = []string{"user", "bot"} + + args := append([]string{"+progress-add", "--format", tt.formatFlag, "--as", "bot"}, tt.args...) + err := runMountedOkrShortcut(t, shortcut, args, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + for _, expected := range tt.expectedOutput { + if !strings.Contains(out, expected) { + t.Errorf("output missing expected string (%s), got: %s", expected, out) + } + } + }) + } +} + +func TestAddProgressValidation(t *testing.T) { + t.Run("missing content", func(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + shortcut := AddProgress + shortcut.AuthTypes = []string{"user", "bot"} + + err := runMountedOkrShortcut(t, shortcut, []string{"+progress-add", "--target-id", "kr-001", "--target-type", "key_result", "--as", "bot"}, f, stdout) + if err == nil { + t.Fatal("expected validation error for missing content") + } + }) +} diff --git a/shortcuts/okr/okr_progress_get.go b/shortcuts/okr/okr_progress_get.go new file mode 100644 index 000000000..f770b3317 --- /dev/null +++ b/shortcuts/okr/okr_progress_get.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/shortcuts/common" +) + +// GetProgress gets a progress record by ID. +var GetProgress = common.Shortcut{ + Service: "okr", + Command: "+progress-get", + Description: "get a progress record by ID", + Risk: "read", + Scopes: []string{"okr:okr.progress:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + + Flags: []common.Flag{ + {Name: "progress-id", Desc: "progress record ID", Required: true}, + }, + + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + progressID := runtime.Str("progress-id") + return common.NewDryRunAPI(). + GET("/open-apis/okr/v1/progress_records/" + progressID). + Params(map[string]interface{}{"user_id_type": "open_id"}) + }, + + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + progressID := runtime.Str("progress-id") + + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", "open_id") + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/okr/v1/progress_records/" + progressID, + QueryParams: queryParams, + }) + + var result map[string]interface{} + if err == nil { + if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { + return WrapOkrError(ErrCodeOkrInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse progress response") + } + } + + data, err := HandleOkrApiResult(result, err, "get progress record") + if err != nil { + return err + } + + runtime.OutFormat(data, nil, func(w io.Writer) { + pID, _ := data["progress_id"].(string) + modifyTime, _ := data["modify_time"].(string) + targetID, _ := data["target_id"].(string) + targetType, _ := data["target_type"].(string) + + targetTypeLabel := "unknown" + switch targetType { + case "2": + targetTypeLabel = "objective" + case "3": + targetTypeLabel = "key_result" + } + + fmt.Fprintf(w, "Progress ID: %s\n", pID) + fmt.Fprintf(w, "Target: %s (%s)\n", targetID, targetTypeLabel) + if modifyTime != "" { + fmt.Fprintf(w, "Modified: %s\n", formatTimestampMs(modifyTime)) + } + + // Extract text from rich text content + if content, ok := data["content"].(map[string]interface{}); ok { + blocks, _ := content["blocks"].([]interface{}) + for _, block := range blocks { + b, ok := block.(map[string]interface{}) + if !ok { + continue + } + if para, ok := b["paragraph"].(map[string]interface{}); ok { + elements, _ := para["elements"].([]interface{}) + for _, elem := range elements { + e, ok := elem.(map[string]interface{}) + if !ok { + continue + } + if textRun, ok := e["textRun"].(map[string]interface{}); ok { + text, _ := textRun["text"].(string) + if text != "" { + fmt.Fprintf(w, "Content: %s\n", text) + } + } + } + } + } + } + }) + + return nil + }, +} diff --git a/shortcuts/okr/okr_progress_get_test.go b/shortcuts/okr/okr_progress_get_test.go new file mode 100644 index 000000000..e3c0fb0ed --- /dev/null +++ b/shortcuts/okr/okr_progress_get_test.go @@ -0,0 +1,92 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestGetProgressRecord(t *testing.T) { + tests := []struct { + name string + progressID string + formatFlag string + expectedOutput []string + }{ + { + name: "pretty format", + progressID: "prog-001", + formatFlag: "pretty", + expectedOutput: []string{ + "Progress ID: prog-001", + "Target: kr-001 (key_result)", + }, + }, + { + name: "json format", + progressID: "prog-001", + formatFlag: "json", + expectedOutput: []string{ + `"progress_id"`, + `"prog-001"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v1/progress_records/" + tt.progressID, + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "progress_id": tt.progressID, + "target_id": "kr-001", + "target_type": "3", + "modify_time": "1700000000000", + "content": map[string]interface{}{ + "blocks": []interface{}{ + map[string]interface{}{ + "type": "paragraph", + "paragraph": map[string]interface{}{ + "elements": []interface{}{ + map[string]interface{}{ + "type": "textRun", + "textRun": map[string]interface{}{ + "text": "Progress update content", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }) + + shortcut := GetProgress + shortcut.AuthTypes = []string{"user", "bot"} + + err := runMountedOkrShortcut(t, shortcut, []string{"+progress-get", "--progress-id", tt.progressID, "--format", tt.formatFlag, "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + for _, expected := range tt.expectedOutput { + if !strings.Contains(out, expected) { + t.Errorf("output missing expected string (%s), got: %s", expected, out) + } + } + }) + } +} diff --git a/shortcuts/okr/okr_review.go b/shortcuts/okr/okr_review.go new file mode 100644 index 000000000..e8606d2bc --- /dev/null +++ b/shortcuts/okr/okr_review.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/shortcuts/common" +) + +// QueryReview queries OKR reviews for given users and period. +var QueryReview = common.Shortcut{ + Service: "okr", + Command: "+review", + Description: "query OKR reviews", + Risk: "read", + Scopes: []string{"okr:okr.review:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + + Flags: []common.Flag{ + {Name: "user-ids", Desc: "comma-separated user open_ids (max 5)", Required: true}, + {Name: "period-id", Desc: "OKR period ID", Required: true}, + }, + + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + ids := splitIDs(runtime.Str("user-ids")) + if len(ids) == 0 { + return fmt.Errorf("--user-ids is required") + } + if len(ids) > 5 { + return fmt.Errorf("--user-ids cannot contain more than 5 IDs (got %d)", len(ids)) + } + return nil + }, + + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + params := map[string]interface{}{ + "user_ids": splitIDs(runtime.Str("user-ids")), + "period_ids": runtime.Str("period-id"), + "user_id_type": "open_id", + } + return common.NewDryRunAPI(). + GET("/open-apis/okr/v1/reviews/query"). + Params(params) + }, + + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + userIDs := splitIDs(runtime.Str("user-ids")) + periodID := runtime.Str("period-id") + + queryParams := make(larkcore.QueryParams) + queryParams.Set("user_id_type", "open_id") + for _, uid := range userIDs { + queryParams.Add("user_ids", uid) + } + queryParams.Add("period_ids", periodID) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: "/open-apis/okr/v1/reviews/query", + QueryParams: queryParams, + }) + + var result map[string]interface{} + if err == nil { + if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil { + return WrapOkrError(ErrCodeOkrInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse review response") + } + } + + data, err := HandleOkrApiResult(result, err, "query OKR reviews") + if err != nil { + return err + } + + reviewList, _ := data["review_list"].([]interface{}) + + outData := map[string]interface{}{ + "review_list": reviewList, + } + + runtime.OutFormat(outData, nil, func(w io.Writer) { + if len(reviewList) == 0 { + fmt.Fprintln(w, "No OKR reviews found.") + return + } + + for i, item := range reviewList { + review, ok := item.(map[string]interface{}) + if !ok { + continue + } + + userObj, _ := review["user_id"].(map[string]interface{}) + openID, _ := userObj["open_id"].(string) + + fmt.Fprintf(w, "[%d] User: %s\n", i+1, openID) + + periodList, _ := review["review_period_list"].([]interface{}) + for _, pItem := range periodList { + rp, ok := pItem.(map[string]interface{}) + if !ok { + continue + } + rpPeriodID, _ := rp["period_id"].(string) + fmt.Fprintf(w, " Period: %s\n", rpPeriodID) + + cycleReviews, _ := rp["cycle_review_list"].([]interface{}) + for _, cr := range cycleReviews { + crMap, ok := cr.(map[string]interface{}) + if !ok { + continue + } + url, _ := crMap["url"].(string) + createTime, _ := crMap["create_time"].(string) + fmt.Fprintf(w, " Cycle Review: %s (created: %s)\n", url, formatTimestampMs(createTime)) + } + + progressReports, _ := rp["progress_report_list"].([]interface{}) + for _, pr := range progressReports { + prMap, ok := pr.(map[string]interface{}) + if !ok { + continue + } + url, _ := prMap["url"].(string) + createTime, _ := prMap["create_time"].(string) + fmt.Fprintf(w, " Progress Report: %s (created: %s)\n", url, formatTimestampMs(createTime)) + } + } + fmt.Fprintln(w) + } + }) + + return nil + }, +} diff --git a/shortcuts/okr/okr_review_test.go b/shortcuts/okr/okr_review_test.go new file mode 100644 index 000000000..3ba3b8bef --- /dev/null +++ b/shortcuts/okr/okr_review_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestQueryReview(t *testing.T) { + tests := []struct { + name string + userIDs string + periodID string + formatFlag string + expectedOutput []string + }{ + { + name: "pretty format", + userIDs: "ou_user1", + periodID: "period-001", + formatFlag: "pretty", + expectedOutput: []string{ + "User: ou_user1", + "Period: period-001", + }, + }, + { + name: "json format", + userIDs: "ou_user1", + periodID: "period-001", + formatFlag: "json", + expectedOutput: []string{ + `"review_list"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v1/reviews/query", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "review_list": []interface{}{ + map[string]interface{}{ + "user_id": map[string]interface{}{ + "open_id": "ou_user1", + }, + "review_period_list": []interface{}{ + map[string]interface{}{ + "period_id": "period-001", + "cycle_review_list": []interface{}{}, + "progress_report_list": []interface{}{}, + }, + }, + }, + }, + }, + }, + }) + + shortcut := QueryReview + shortcut.AuthTypes = []string{"user", "bot"} + + err := runMountedOkrShortcut(t, shortcut, []string{"+review", "--user-ids", tt.userIDs, "--period-id", tt.periodID, "--format", tt.formatFlag, "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + for _, expected := range tt.expectedOutput { + if !strings.Contains(out, expected) { + t.Errorf("output missing expected string (%s), got: %s", expected, out) + } + } + }) + } +} + +func TestQueryReviewValidation(t *testing.T) { + t.Run("too many user IDs", func(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + shortcut := QueryReview + shortcut.AuthTypes = []string{"user", "bot"} + + err := runMountedOkrShortcut(t, shortcut, []string{"+review", "--user-ids", "1,2,3,4,5,6", "--period-id", "p1", "--as", "bot"}, f, stdout) + if err == nil { + t.Fatal("expected validation error for too many user IDs") + } + }) +} + +func TestQueryReviewEmpty(t *testing.T) { + f, stdout, _, reg := okrShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/okr/v1/reviews/query", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "review_list": []interface{}{}, + }, + }, + }) + + shortcut := QueryReview + shortcut.AuthTypes = []string{"user", "bot"} + + err := runMountedOkrShortcut(t, shortcut, []string{"+review", "--user-ids", "ou_user1", "--period-id", "p1", "--format", "pretty", "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "No OKR reviews found") { + t.Errorf("expected empty message, got: %s", out) + } +} diff --git a/shortcuts/okr/okr_shortcut_test.go b/shortcuts/okr/okr_shortcut_test.go new file mode 100644 index 000000000..c699a0d5b --- /dev/null +++ b/shortcuts/okr/okr_shortcut_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "bytes" + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func okrTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + suffix := strings.NewReplacer("/", "-", " ", "-", ":", "-", "\t", "-").Replace(t.Name()) + return &core.CliConfig{ + AppID: "test-app-" + suffix, + AppSecret: "test-secret-" + suffix, + Brand: core.BrandFeishu, + UserOpenId: "ou_testuser", + UserName: "Test User", + } +} + +func warmTenantToken(t *testing.T, f *cmdutil.Factory, reg *httpmock.Registry) { + t.Helper() + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/test/v1/warm", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + s := common.Shortcut{ + Service: "test", + Command: "+warm-token", + AuthTypes: []string{"bot"}, + Execute: func(_ context.Context, rctx *common.RuntimeContext) error { + _, err := rctx.CallAPI("GET", "/open-apis/test/v1/warm", nil, nil) + return err + }, + } + + parent := &cobra.Command{Use: "test"} + s.Mount(parent, f) + parent.SetArgs([]string{"+warm-token", "--as", "bot"}) + parent.SilenceErrors = true + parent.SilenceUsage = true + if err := parent.Execute(); err != nil { + t.Fatalf("warm tenant token: %v", err) + } +} + +func okrShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + return cmdutil.TestFactory(t, okrTestConfig(t)) +} + +func runMountedOkrShortcut(t *testing.T, shortcut common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "test"} + shortcut.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} diff --git a/shortcuts/okr/okr_util.go b/shortcuts/okr/okr_util.go new file mode 100644 index 000000000..7483f715d --- /dev/null +++ b/shortcuts/okr/okr_util.go @@ -0,0 +1,220 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + // ErrCodeOkrInvalidParams is returned when request parameters are invalid. + ErrCodeOkrInvalidParams = 1001001 + // ErrCodeOkrPermDenied is returned when the user has no permission. + ErrCodeOkrPermDenied = 1001002 + // ErrCodeOkrUserNotFound is returned when the user is not found. + ErrCodeOkrUserNotFound = 1001003 + // ErrCodeOkrNotFound is returned when OKR data is not found. + ErrCodeOkrNotFound = 1001004 + // ErrCodeOkrDuplicatePeriod is returned when a period date conflicts. + ErrCodeOkrDuplicatePeriod = 1001008 + // ErrCodeOkrEditionRequired is returned when a higher edition is required. + ErrCodeOkrEditionRequired = 1000403 + // ErrCodeOkrSystemException is returned for system exceptions. + ErrCodeOkrSystemException = 1009998 + // ErrCodeOkrInternalError is returned for unknown internal errors. + ErrCodeOkrInternalError = 1009999 +) + +// OkrErrorInfo maps Lark error codes to standardized error info. +type OkrErrorInfo struct { + Type string + Message string + Hint string + ExitCode int +} + +var okrErrorMap = map[int]OkrErrorInfo{ + ErrCodeOkrInvalidParams: {"validation_error", "Invalid request parameters", "Please check required fields and parameter values.", output.ExitValidation}, + ErrCodeOkrPermDenied: {"permission_error", "Permission denied", "Please check if the calling identity has the necessary OKR permissions. Run: lark-cli auth login --domain okr", output.ExitAPI}, + ErrCodeOkrUserNotFound: {"not_found", "User not found", "Please verify the user ID is correct.", output.ExitAPI}, + ErrCodeOkrNotFound: {"not_found", "OKR data not found", "Please verify the OKR, objective, or key result ID is correct.", output.ExitAPI}, + ErrCodeOkrDuplicatePeriod: {"conflict", "Duplicate period or date conflict", "A period with overlapping dates already exists.", output.ExitAPI}, + ErrCodeOkrEditionRequired: {"permission_error", "Business edition or above required", "This operation requires Feishu Business edition or above.", output.ExitAPI}, + ErrCodeOkrSystemException: {"api_error", "System exception", "Please try again. If the error persists, contact support.", output.ExitAPI}, + ErrCodeOkrInternalError: {"api_error", "Internal server error", "Please try again. If the error persists, contact support.", output.ExitAPI}, +} + +// WrapOkrError wraps a Lark API error into a standardized ExitError based on OKR-specific rules. +func WrapOkrError(larkCode int, rawMsg string, action string) error { + info, ok := okrErrorMap[larkCode] + if !ok { + exitCode, errType, hint := output.ClassifyLarkError(larkCode, rawMsg) + + genericMsg := "API error" + switch errType { + case "permission": + genericMsg = "Permission denied" + case "auth": + genericMsg = "Authentication failed" + case "config": + genericMsg = "Configuration error" + case "rate_limit": + genericMsg = "Rate limit exceeded" + } + + displayMsg := fmt.Sprintf("%s: %s [%d] (Details: %s)", action, genericMsg, larkCode, rawMsg) + return &output.ExitError{ + Code: exitCode, + Detail: &output.ErrDetail{ + Type: errType, + Code: larkCode, + Message: displayMsg, + Hint: hint, + }, + } + } + + return &output.ExitError{ + Code: info.ExitCode, + Detail: &output.ErrDetail{ + Type: info.Type, + Code: larkCode, + Message: fmt.Sprintf("%s: %s (Details: %s)", action, info.Message, rawMsg), + Hint: info.Hint, + }, + } +} + +// HandleOkrApiResult checks for network/API errors and returns the "data" field. +func HandleOkrApiResult(result interface{}, err error, action string) (map[string]interface{}, error) { + if err != nil { + return nil, err + } + + resultMap, _ := result.(map[string]interface{}) + codeVal, hasCode := resultMap["code"] + if !hasCode { + data, err := common.HandleApiResult(result, err, action) + return data, err + } + + code, _ := util.ToFloat64(codeVal) + larkCode := int(code) + if larkCode != 0 { + rawMsg, _ := resultMap["msg"].(string) + return nil, WrapOkrError(larkCode, rawMsg, action) + } + + data, _ := resultMap["data"].(map[string]interface{}) + return data, nil +} + +// findCurrentPeriod returns the first period whose time range covers now and status is normal (0). +func findCurrentPeriod(periods []interface{}) map[string]interface{} { + now := time.Now().UnixMilli() + for _, p := range periods { + period, ok := p.(map[string]interface{}) + if !ok { + continue + } + status, _ := util.ToFloat64(period["status"]) + if int(status) != 0 { + continue + } + startStr, _ := period["period_start_time"].(string) + endStr, _ := period["period_end_time"].(string) + start, _ := strconv.ParseInt(startStr, 10, 64) + end, _ := strconv.ParseInt(endStr, 10, 64) + if start <= now && now <= end { + return period + } + } + return nil +} + +// splitIDs splits a comma-separated string into a slice, trimming whitespace. +func splitIDs(s string) []string { + parts := strings.Split(s, ",") + var result []string + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + result = append(result, p) + } + } + return result +} + +// formatProgressPercent formats a progress_rate object into a display string. +func formatProgressPercent(progressRate map[string]interface{}) string { + percent, _ := util.ToFloat64(progressRate["percent"]) + statusStr, _ := progressRate["status"].(string) + + var statusLabel string + switch statusStr { + case "0": + statusLabel = "normal" + case "1": + statusLabel = "at risk" + case "2": + statusLabel = "delayed" + default: + statusLabel = "" + } + + if statusLabel != "" { + return fmt.Sprintf("%.0f%% (%s)", percent, statusLabel) + } + return fmt.Sprintf("%.0f%%", percent) +} + +// formatTimestampMs formats a millisecond timestamp string to local time. +func formatTimestampMs(tsStr string) string { + ts, err := strconv.ParseInt(tsStr, 10, 64) + if err != nil || ts == 0 { + return "" + } + return time.UnixMilli(ts).Local().Format("2006-01-02") +} + +// resolveTargetType converts a human-readable target type to the API numeric value. +// Accepts: "objective" or "2" → "2", "key_result" or "3" → "3". +func resolveTargetType(input string) (string, error) { + switch input { + case "objective", "2": + return "2", nil + case "key_result", "3": + return "3", nil + default: + return "", fmt.Errorf("invalid --target-type %q: must be objective, key_result, 2, or 3", input) + } +} + +// wrapPlainTextContent converts a plain text string into Lark rich text format for progress records. +func wrapPlainTextContent(text string) map[string]interface{} { + return map[string]interface{}{ + "blocks": []interface{}{ + map[string]interface{}{ + "type": "paragraph", + "paragraph": map[string]interface{}{ + "elements": []interface{}{ + map[string]interface{}{ + "type": "textRun", + "textRun": map[string]interface{}{ + "text": text, + }, + }, + }, + }, + }, + }, + } +} diff --git a/shortcuts/okr/okr_util_test.go b/shortcuts/okr/okr_util_test.go new file mode 100644 index 000000000..5b0cb4e3a --- /dev/null +++ b/shortcuts/okr/okr_util_test.go @@ -0,0 +1,194 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitIDs(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + {"single", "id1", []string{"id1"}}, + {"multiple", "id1,id2,id3", []string{"id1", "id2", "id3"}}, + {"with spaces", " id1 , id2 , id3 ", []string{"id1", "id2", "id3"}}, + {"empty parts", "id1,,id2", []string{"id1", "id2"}}, + {"empty string", "", nil}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := splitIDs(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatProgressPercent(t *testing.T) { + tests := []struct { + name string + progressRate map[string]interface{} + expected string + }{ + { + "normal", + map[string]interface{}{"percent": float64(75), "status": "0"}, + "75% (normal)", + }, + { + "at risk", + map[string]interface{}{"percent": float64(30), "status": "1"}, + "30% (at risk)", + }, + { + "delayed", + map[string]interface{}{"percent": float64(10), "status": "2"}, + "10% (delayed)", + }, + { + "no status", + map[string]interface{}{"percent": float64(50), "status": "-1"}, + "50%", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatProgressPercent(tt.progressRate) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFormatTimestampMs(t *testing.T) { + tests := []struct { + name string + input string + isEmpty bool + }{ + {"valid timestamp", "1700000000000", false}, + {"zero", "0", true}, + {"empty", "", true}, + {"invalid", "abc", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatTimestampMs(tt.input) + if tt.isEmpty { + assert.Empty(t, result) + } else { + assert.NotEmpty(t, result) + } + }) + } +} + +func TestFindCurrentPeriod(t *testing.T) { + t.Run("no periods", func(t *testing.T) { + result := findCurrentPeriod(nil) + assert.Nil(t, result) + }) + + t.Run("no matching period", func(t *testing.T) { + periods := []interface{}{ + map[string]interface{}{ + "id": "p1", + "status": float64(0), + "period_start_time": "0", + "period_end_time": "1000", + }, + } + result := findCurrentPeriod(periods) + assert.Nil(t, result) + }) +} + +func TestWrapPlainTextContent(t *testing.T) { + result := wrapPlainTextContent("Hello OKR") + + blocks, ok := result["blocks"].([]interface{}) + assert.True(t, ok) + assert.Len(t, blocks, 1) + + block, ok := blocks[0].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "paragraph", block["type"]) + + para, ok := block["paragraph"].(map[string]interface{}) + assert.True(t, ok) + + elements, ok := para["elements"].([]interface{}) + assert.True(t, ok) + assert.Len(t, elements, 1) + + elem, ok := elements[0].(map[string]interface{}) + assert.True(t, ok) + + textRun, ok := elem["textRun"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, "Hello OKR", textRun["text"]) +} + +func TestResolveTargetType(t *testing.T) { + tests := []struct { + input string + expected string + hasError bool + }{ + {"objective", "2", false}, + {"key_result", "3", false}, + {"2", "2", false}, + {"3", "3", false}, + {"invalid", "", true}, + {"", "", true}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + result, err := resolveTargetType(tt.input) + if tt.hasError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestWrapOkrError(t *testing.T) { + err := WrapOkrError(ErrCodeOkrNotFound, "not found", "test action") + assert.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestHandleOkrApiResult(t *testing.T) { + t.Run("success", func(t *testing.T) { + result := map[string]interface{}{ + "code": float64(0), + "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{}, + }, + } + data, err := HandleOkrApiResult(result, nil, "test") + assert.NoError(t, err) + assert.NotNil(t, data) + }) + + t.Run("api error", func(t *testing.T) { + result := map[string]interface{}{ + "code": float64(1001004), + "msg": "not found", + } + _, err := HandleOkrApiResult(result, nil, "test") + assert.Error(t, err) + }) +} diff --git a/shortcuts/okr/shortcuts.go b/shortcuts/okr/shortcuts.go new file mode 100644 index 000000000..0cd3aa4f6 --- /dev/null +++ b/shortcuts/okr/shortcuts.go @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "github.com/larksuite/cli/shortcuts/common" +) + +// Shortcuts returns all shortcuts for the OKR domain. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + ListOKR, + GetOKR, + ListPeriods, + AddProgress, + GetProgress, + QueryReview, + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 79b088149..7e947f96b 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -18,6 +18,7 @@ import ( "github.com/larksuite/cli/shortcuts/im" "github.com/larksuite/cli/shortcuts/mail" "github.com/larksuite/cli/shortcuts/minutes" + "github.com/larksuite/cli/shortcuts/okr" "github.com/larksuite/cli/shortcuts/sheets" "github.com/larksuite/cli/shortcuts/task" "github.com/larksuite/cli/shortcuts/vc" @@ -39,6 +40,7 @@ func init() { allShortcuts = append(allShortcuts, event.Shortcuts()...) allShortcuts = append(allShortcuts, mail.Shortcuts()...) allShortcuts = append(allShortcuts, minutes.Shortcuts()...) + allShortcuts = append(allShortcuts, okr.Shortcuts()...) allShortcuts = append(allShortcuts, task.Shortcuts()...) allShortcuts = append(allShortcuts, vc.Shortcuts()...) allShortcuts = append(allShortcuts, whiteboard.Shortcuts()...) diff --git a/skills/lark-okr/SKILL.md b/skills/lark-okr/SKILL.md new file mode 100644 index 000000000..525374fc0 --- /dev/null +++ b/skills/lark-okr/SKILL.md @@ -0,0 +1,99 @@ +--- +name: lark-okr +version: 1.0.0 +description: "飞书 OKR:查看和管理 OKR 目标与关键结果。查看当前周期 OKR、更新进展记录、查询 OKR 复盘。当用户需要查看 OKR 进度、添加进展、查看团队 OKR 或进行 OKR 复盘时使用。" +metadata: + requires: + bins: ["lark-cli"] + cliHelp: "lark-cli okr --help" +--- + +# okr (v1) + +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** + +> **身份要求**:OKR API 仅支持 **user 身份** (user_access_token)。不支持 bot 身份。 +> **重要限制**:OKR、Objective、Key Result 的**创建与修改只能在飞书 UI 中完成**,API 仅支持读取和进展管理。 +> **周期识别**:当用户未指定周期时,`+list` 会自动查询当前活跃周期。如需指定周期,先用 `+periods` 获取可用周期列表。 +> **用户身份**:当用户提到"我的 OKR"时,无需额外参数,`+list` 默认使用当前登录用户。查看他人 OKR 时需提供 `--user-id`。 +> **ID 说明**:OKR API 中的 ID(okr_id、objective_id、kr_id)均为 OKR 系统内部 ID,不是客户端展示的编号。 + +> **进展记录注意**: +> 1. 创建进展记录需提供 `target_id`(Objective 或 KR 的 ID)和 `--target-type`(`objective` 或 `key_result`)。 +> 2. `--text` 提供纯文本输入,会自动转换为飞书富文本格式;`--content` 支持完整的富文本 JSON。 +> 3. 进展记录需要 `source_url`(必须是 http/https 开头的 URL),默认使用 lark-cli 的 GitHub 地址。 + +> **查询注意**: +> 1. OKR 列表接口最多每页返回 10 条(`--limit` 最大为 10)。 +> 2. `batch_get` 接口最多同时查询 10 个 OKR ID。 +> 3. 复盘查询最多 5 个 user_id 和 5 个 period_id。 +> 4. 所有时间戳为**毫秒**级 Unix 时间。 + +## Shortcuts + +- [`+list`](./references/lark-okr-list.md) — 查看用户 OKR(默认当前用户 + 当前周期) +- [`+get`](./references/lark-okr-get.md) — 按 ID 批量获取 OKR 详情 +- [`+periods`](./references/lark-okr-periods.md) — 列出 OKR 周期 +- [`+progress-add`](./references/lark-okr-progress-add.md) — 添加进展记录 +- [`+progress-get`](./references/lark-okr-progress-get.md) — 按 ID 获取进展记录详情 +- [`+review`](./references/lark-okr-review.md) — 查询 OKR 复盘信息 + +## API Resources + +```bash +lark-cli schema okr.. # 调用 API 前必须先查看参数结构 +lark-cli okr [flags] # 调用 API +``` + +> **重要**:使用原生 API 时,必须先运行 `schema` 查看 `--data` / `--params` 参数结构,不要猜测字段格式。 + +### okrs + + - `batch_get` — 批量获取 OKR + +### users (okr) + + - `okrs` — 获取用户 OKR 列表 + +### periods + + - `list` — 获取 OKR 周期列表 + - `create` — 创建 OKR 周期 + - `patch` — 更新 OKR 周期状态 + +### period_rules + + - `list` — 获取 OKR 周期规则列表 + +### progress_records + + - `create` — 创建进展记录 + - `get` — 获取进展记录详情 + - `update` — 更新进展记录 + - `delete` — 删除进展记录 + +### images + + - `upload` — 上传进展图片 + +### reviews + + - `query` — 查询 OKR 复盘信息 + +### metric_sources + + - `list` — 获取指标源列表 + - `tables` — 获取指标表列表 + - `items` — 获取/更新指标项 + +## 权限表 + +| Scope | 说明 | +|-------|------| +| `okr:okr:readonly` | 读取 OKR 信息 | +| `okr:okr` | 读取 + 更新 OKR 信息 | +| `okr:okr.progress:readonly` | 读取进展记录 | +| `okr:okr.progress:writeonly` | 创建/更新进展记录 | +| `okr:okr.progress:delete` | 删除进展记录 | +| `okr:okr.period:readonly` | 读取周期信息 | +| `okr:okr.review:readonly` | 读取复盘信息 | diff --git a/skills/lark-okr/references/lark-okr-get.md b/skills/lark-okr/references/lark-okr-get.md new file mode 100644 index 000000000..44ce827b9 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-get.md @@ -0,0 +1,41 @@ + +# okr +get + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +按 OKR ID 批量获取 OKR 详情,包含 Objective、Key Result 及进度信息。只读操作。 + +需要的 scopes: ["okr:okr:readonly"] + +## 命令 + +```bash +# 获取单个 OKR 详情 +lark-cli okr +get --okr-ids "okr_xxx" + +# 批量获取多个 OKR(最多 10 个,逗号分隔) +lark-cli okr +get --okr-ids "okr_xxx,okr_yyy" + +# 预览 API 调用 +lark-cli okr +get --okr-ids "okr_xxx" --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--okr-ids ` | 是 | 逗号分隔的 OKR ID(最多 10 个) | +| `--lang ` | 否 | 语言:`zh_cn`(默认)或 `en_us` | +| `--format` | 否 | 输出格式:json(默认)\| pretty | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 工作流 + +1. 从 `+list` 的输出中获取 OKR ID,或由用户提供。 +2. 执行 `lark-cli okr +get --okr-ids "..."`。 +3. 展示完整的 OKR 结构:各 Objective 内容、进度、嵌套的 Key Result 及其进度。 + +## 参考 + +- [lark-okr](../SKILL.md) — 所有 OKR 命令 +- [lark-shared](../../lark-shared/SKILL.md) — 认证与全局参数 diff --git a/skills/lark-okr/references/lark-okr-list.md b/skills/lark-okr/references/lark-okr-list.md new file mode 100644 index 000000000..305858066 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-list.md @@ -0,0 +1,53 @@ + +# okr +list + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +查看用户的 OKR 列表。默认查看当前登录用户在当前活跃周期的 OKR。只读操作。 + +需要的 scopes: ["okr:okr:readonly"] + +> **⚠️ 注意:** 此 API 仅支持 user 身份调用。**不可使用 bot 身份,否则调用将失败。** + +## 命令 + +```bash +# 查看我的 OKR(默认当前用户 + 自动检测当前周期) +lark-cli okr +list + +# 指定用户 +lark-cli okr +list --user-id "ou_xxx" + +# 指定周期 +lark-cli okr +list --period-id "period_xxx" + +# 指定用户 + 周期 +lark-cli okr +list --user-id "ou_xxx" --period-id "period_xxx" + +# 预览 API 调用,不执行 +lark-cli okr +list --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--user-id ` | 否 | 用户 `open_id`,省略则使用当前登录用户 | +| `--period-id ` | 否 | OKR 周期 ID。省略则自动检测当前活跃周期。可通过 `+periods` 获取可用周期列表 | +| `--lang ` | 否 | 语言:`zh_cn`(默认)或 `en_us` | +| `--offset ` | 否 | 分页偏移(默认 0) | +| `--limit ` | 否 | 每页数量(默认 10,最大 10) | +| `--format` | 否 | 输出格式:json(默认)\| pretty | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 工作流 + +1. 如果用户想看"我的 OKR",直接运行 `lark-cli okr +list` 即可(无需 `--user-id`)。 +2. 如果用户想看特定周期的 OKR,先运行 `lark-cli okr +periods` 获取周期 ID,再传入 `--period-id`。 +3. 如果用户想看他人的 OKR,需获取对方的 `open_id`(例如通过 `lark-cli contact +get-user`),再传入 `--user-id`。 +4. 展示结果:列出各 Objective 及其 Key Result,附带进度百分比。 + +## 参考 + +- [lark-okr](../SKILL.md) — 所有 OKR 命令 +- [lark-shared](../../lark-shared/SKILL.md) — 认证与全局参数 diff --git a/skills/lark-okr/references/lark-okr-periods.md b/skills/lark-okr/references/lark-okr-periods.md new file mode 100644 index 000000000..045a3980c --- /dev/null +++ b/skills/lark-okr/references/lark-okr-periods.md @@ -0,0 +1,46 @@ + +# okr +periods + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +列出 OKR 周期。用于获取 `period_id`,作为其他 OKR 命令的前置查询。只读操作。 + +需要的 scopes: ["okr:okr.period:readonly"] + +## 命令 + +```bash +# 列出 OKR 周期 +lark-cli okr +periods + +# 指定分页大小 +lark-cli okr +periods --page-size 20 + +# 预览 API 调用 +lark-cli okr +periods --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--page-token ` | 否 | 分页 token,用于翻页 | +| `--page-size ` | 否 | 每页数量(默认 10) | +| `--format` | 否 | 输出格式:json(默认)\| pretty | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 输出说明 + +- pretty 格式下,当前活跃周期会标注 `[current]`。 +- 输出包含周期名称、ID、起止时间。 + +## 工作流 + +1. 运行 `lark-cli okr +periods` 列出所有可用周期。 +2. 找到目标周期的 `id`(当前活跃周期标有 `[current]`)。 +3. 将周期 ID 传入 `+list --period-id "..."` 等命令。 + +## 参考 + +- [lark-okr](../SKILL.md) — 所有 OKR 命令 +- [lark-shared](../../lark-shared/SKILL.md) — 认证与全局参数 diff --git a/skills/lark-okr/references/lark-okr-progress-add.md b/skills/lark-okr/references/lark-okr-progress-add.md new file mode 100644 index 000000000..c30761854 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-progress-add.md @@ -0,0 +1,67 @@ + +# okr +progress-add + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +为 OKR 的 Objective 或 Key Result 添加进展记录。写操作。 + +需要的 scopes: ["okr:okr.progress:writeonly"] + +## 命令 + +```bash +# 为 Key Result 添加纯文本进展 +lark-cli okr +progress-add \ + --target-id "kr_xxx" \ + --target-type key_result \ + --text "本周完成了 80% 的开发工作" + +# 为 Objective 添加进展 +lark-cli okr +progress-add \ + --target-id "obj_xxx" \ + --target-type objective \ + --text "各子项目均按计划推进" + +# 使用富文本 JSON(从文件读取) +lark-cli okr +progress-add \ + --target-id "kr_xxx" \ + --target-type key_result \ + --content @progress.json + +# 预览 API 调用 +lark-cli okr +progress-add \ + --target-id "kr_xxx" \ + --target-type key_result \ + --text "测试" \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--target-id ` | 是 | 目标 Objective 或 Key Result 的 ID | +| `--target-type ` | 是 | 目标类型:`objective` 或 `key_result`(也兼容 `2`/`3`) | +| `--text ` | 否* | 纯文本进展内容(自动转换为飞书富文本格式) | +| `--content ` | 否* | 富文本 JSON 内容。支持 `@file`(从文件读取)和 `-`(从 stdin 读取) | +| `--source-title ` | 否 | 来源标题(默认 "lark-cli") | +| `--source-url <url>` | 否 | 来源 URL(默认 "https://github.com/larksuite/cli"),必须以 http/https 开头 | +| `--data <json>` | 否 | 完整 JSON payload(覆盖其他参数) | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +\* `--text`、`--content`、`--data` 三者至少提供一个。 + +## 工作流 + +1. 获取目标 ID:使用 `+list` 或 `+get` 获取 Objective 或 Key Result 的 ID。 +2. 与用户确认:目标、进展内容、目标类型。 +3. 执行 `lark-cli okr +progress-add --target-id "..." --target-type key_result --text "..."`。 +4. 展示结果:progress record ID。 + +> [!CAUTION] +> 这是一个**写操作** — 执行前必须确认用户意图。 + +## 参考 + +- [lark-okr](../SKILL.md) — 所有 OKR 命令 +- [lark-shared](../../lark-shared/SKILL.md) — 认证与全局参数 diff --git a/skills/lark-okr/references/lark-okr-progress-get.md b/skills/lark-okr/references/lark-okr-progress-get.md new file mode 100644 index 000000000..3132444b1 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-progress-get.md @@ -0,0 +1,37 @@ + +# okr +progress-get + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +按 ID 获取进展记录详情,包含进展内容、目标信息、修改时间。只读操作。 + +需要的 scopes: ["okr:okr.progress:readonly"] + +## 命令 + +```bash +# 获取进展记录详情 +lark-cli okr +progress-get --progress-id "prog_xxx" + +# 预览 API 调用 +lark-cli okr +progress-get --progress-id "prog_xxx" --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--progress-id <id>` | 是 | 进展记录 ID | +| `--format` | 否 | 输出格式:json(默认)\| pretty | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 工作流 + +1. 从 `+get` 输出中获取进展记录 ID(各 Objective/KR 包含 `progress_record_list`)。 +2. 执行 `lark-cli okr +progress-get --progress-id "..."`。 +3. 展示进展内容、目标信息、修改时间。 + +## 参考 + +- [lark-okr](../SKILL.md) — 所有 OKR 命令 +- [lark-shared](../../lark-shared/SKILL.md) — 认证与全局参数 diff --git a/skills/lark-okr/references/lark-okr-review.md b/skills/lark-okr/references/lark-okr-review.md new file mode 100644 index 000000000..83c911311 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-review.md @@ -0,0 +1,42 @@ + +# okr +review + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +查询指定用户在指定周期的 OKR 复盘信息,包含周期复盘链接和进展报告链接。只读操作。 + +需要的 scopes: ["okr:okr.review:readonly"] + +## 命令 + +```bash +# 查询单个用户的复盘 +lark-cli okr +review --user-ids "ou_xxx" --period-id "period_xxx" + +# 查询多个用户的复盘(最多 5 个,逗号分隔) +lark-cli okr +review --user-ids "ou_xxx,ou_yyy" --period-id "period_xxx" + +# 预览 API 调用 +lark-cli okr +review --user-ids "ou_xxx" --period-id "period_xxx" --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--user-ids <ids>` | 是 | 逗号分隔的用户 `open_id`(最多 5 个) | +| `--period-id <id>` | 是 | OKR 周期 ID。可通过 `+periods` 获取 | +| `--format` | 否 | 输出格式:json(默认)\| pretty | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 工作流 + +1. 获取用户 ID:通过 `lark-cli contact +get-user` 或由用户提供。 +2. 获取周期 ID:通过 `lark-cli okr +periods` 或由用户提供。 +3. 执行 `lark-cli okr +review --user-ids "..." --period-id "..."`。 +4. 展示复盘信息,包括周期复盘 URL 和进展报告 URL。 + +## 参考 + +- [lark-okr](../SKILL.md) — 所有 OKR 命令 +- [lark-shared](../../lark-shared/SKILL.md) — 认证与全局参数 diff --git a/skills/lark-workflow-okr-review/SKILL.md b/skills/lark-workflow-okr-review/SKILL.md new file mode 100644 index 000000000..40a3efb03 --- /dev/null +++ b/skills/lark-workflow-okr-review/SKILL.md @@ -0,0 +1,127 @@ +--- +name: lark-workflow-okr-review +version: 1.0.0 +description: "OKR 复盘工作流:汇总指定周期 OKR 进展,结合任务完成情况,生成结构化复盘报告。适用于 OKR 进展汇总、周期末复盘、团队 OKR 报告。" +metadata: + requires: + bins: ["lark-cli"] +--- + +# OKR 复盘报告工作流 + +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理**。然后阅读 [`../lark-okr/SKILL.md`](../lark-okr/SKILL.md),了解 OKR 相关操作。 + +## 适用场景 + +- "帮我整理这个季度的 OKR 复盘" / "查看我的 OKR 进展" +- "生成 OKR 进展汇总报告" / "OKR 复盘" +- "查看我的 OKR 和相关任务完成情况" +- "帮我写 OKR 复盘总结" + +## 前置条件 + +仅支持 **user 身份**。执行前确保已授权: + +```bash +lark-cli auth login --domain okr # 基础(查看 OKR + 进展) +lark-cli auth login --domain okr,task # 含关联任务(推荐) +``` + +## 工作流 + +``` +{周期} ─► okr +periods ──► 获取周期 ID + │ + ▼ + okr +list ──► OKR 列表(Objective + KR + 进度百分比) + │ + ▼ + okr +get ──► 各 OKR 详情(含 progress_record_list) + │ + ▼ + task +get-my-tasks ──► 关联任务完成情况(可选) + │ + ▼ + AI 汇总 ──► 结构化复盘报告 + │ + ▼ + doc +create ──► 写入飞书文档(可选) +``` + +### Step 1: 确定周期 + +默认**当前周期**。如果用户指定了周期(如"Q1"、"上个季度"),需要先列出周期来匹配。 + +```bash +lark-cli okr +periods --format json +``` + +从返回结果中找到目标周期的 `id`。`[current]` 标记的是当前活跃周期。 + +### Step 2: 获取 OKR 列表 + +```bash +# 当前用户当前周期(默认) +lark-cli okr +list --format json + +# 指定周期 +lark-cli okr +list --period-id "period_xxx" --format json +``` + +输出包含:OKR ID、name、objective_list(含 content、progress_rate、kr_list)。 + +### Step 3: 获取 OKR 详情(可选,需要进展记录时) + +```bash +lark-cli okr +get --okr-ids "okr_xxx" --format json +``` + +输出包含完整的 Objective 和 KR 结构,含 `progress_record_list`(进展记录引用列表)。 + +### Step 4: 获取关联任务(可选) + +```bash +lark-cli task +get-my-tasks --format json +``` + +将任务与 OKR 的 KR 进行关联匹配,展示哪些任务支撑了哪些 KR。 + +### Step 5: AI 汇总生成报告 + +结合以上数据,生成结构化复盘报告: + +```markdown +# OKR 复盘 — {周期名称} + +## 概览 +- 目标数:N 个 +- 整体进度:XX% + +## 各目标详情 + +### O1: {目标内容} — {进度}% +- KR1: {内容} — {进度}% ({状态}) +- KR2: {内容} — {进度}% ({状态}) + +### O2: ... + +## 关联任务完成情况(可选) +- 已完成:N 个 +- 进行中:N 个 + +## 总结与反思 +{AI 基于数据生成的分析} +``` + +### Step 6: 写入飞书文档(可选) + +```bash +lark-cli docs +create --title "OKR 复盘 — Q1 2026" --markdown "<report_content>" +``` + +## 注意事项 + +1. **OKR 不可通过 API 修改**:如果用户要求修改 OKR 内容(如调整 Objective 描述、修改 KR),需引导用户在飞书 UI 中操作。 +2. **进展记录可通过 API 创建**:如果复盘过程中用户想添加进展,可使用 `okr +progress-add`。 +3. **时间戳转换**:API 返回的时间戳为毫秒级,展示时需转换为本地时间。 +4. **权限**:查看他人 OKR 需要对方的 OKR 设置允许被查看。