Compare commits

...

5 Commits

Author SHA1 Message Date
liangshuo-1
bba13cfe0f chore: release v1.0.56 (#1518) 2026-06-18 18:53:21 +08:00
liujiashu-shiro
815cdb8f1c feat(im/convert): support content_v2 blocks in post message conversion (#1411)
Support content_v2 post message conversion in IM shortcuts so newer post payloads render with the expected markdown, mention, and image formats while preserving fallback compatibility with legacy content.
2026-06-18 17:53:22 +08:00
liangshuo-1
4f3ae0c71a fix: pin fetch_meta.py output to utf-8 encoding (#1516) 2026-06-18 17:18:45 +08:00
91-enjoy
96d70143c5 feat: support message recieve event card format (#1480)
Previously, im.message.receive events with message_type: interactive surfaced the raw JSON
payload as content, requiring callers to manually parse the card schema. This PR introduces a
user_dsl renderer (ConvertInteractiveEventContent) that converts interactive card content into
structured human-readable text — consistent with how text, post, image, and other message
types are already handled.

The output format is <card title="..." subtitle="...">...</card>, with each card element type
serialised to a readable representation (markdown body, button links, table rows, chart summaries,
etc.).
2026-06-18 17:18:01 +08:00
syh-cpdsss
83db15907f Improve OKR shortcuts (#1487)
* feat(okr): add +batch-create, +reorder, +weight shortcuts

Add three new OKR shortcuts for managing objectives and key results:

- +batch-create: Bulk create objectives with key results, with automatic
  rollback on failure
- +reorder: Adjust position of objectives or key results within a cycle/objective
- +weight: Adjust weights of objectives or key results with automatic
  normalization using fixed-point arithmetic to avoid float precision issues

Key implementation details:
- API paths use underscore separators (/objectives_position, /objectives_weight)
- Weight normalization uses json.Number for precise JSON serialization
- Items are sorted by position before API calls to match backend requirements
- Full unit test coverage and dry-run/live E2E tests
- Skill documentation with usage examples and parameter descriptions

Change-Id: I92b658e0cc42ffa8cbdaec2ec628a079bcfc38f5

* fix: skill simplify & minor fix

Change-Id: I3f27a01cdae2122f26e48ee2acb7f334f2bab7d2

* fix: CR issue

Change-Id: Id9fab84e06f0d67e9f79c1fb9946b6b633200592

* fix: CR issue 2

Change-Id: I6a5e57dd4b10dc79f8681ec614354fbba82abc04

* fix: error handle of +weight shortcut

Change-Id: I6e2a39269e62e3b504e681110843b2ccc315a527
2026-06-18 16:25:23 +08:00
29 changed files with 7023 additions and 37 deletions

View File

@@ -2,6 +2,29 @@
All notable changes to this project will be documented in this file.
## [v1.0.56] - 2026-06-18
### Features
- **apps**: Add `+session-messages-list` for session turn reply messages (#1402)
### Bug Fixes
- **api**: Align API success envelopes (#1489)
- **base**: Reject out-of-range pagination flags (#1495)
### Refactor
- Retire legacy error envelopes and enforce typed contract (#1449)
### Documentation
- **skills**: Soften lark-doc style guidance (#1463)
### Build
- Add CI quality gate with semantic review
## [v1.0.55] - 2026-06-16
### Features
@@ -1189,6 +1212,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.56]: https://github.com/larksuite/cli/releases/tag/v1.0.56
[v1.0.55]: https://github.com/larksuite/cli/releases/tag/v1.0.55
[v1.0.54]: https://github.com/larksuite/cli/releases/tag/v1.0.54
[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53

View File

@@ -23,7 +23,7 @@ type ImMessageReceiveOutput struct {
ChatType string `json:"chat_type,omitempty" desc:"Conversation type" enum:"p2p,group"`
MessageType string `json:"message_type,omitempty" desc:"Message type"`
SenderID string `json:"sender_id,omitempty" desc:"Sender open_id; prefixed with ou_" kind:"open_id"`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text. For interactive (cards) it stays as the raw JSON string and callers must fromjson to parse it."`
Content string `json:"content,omitempty" desc:"Message content. For most types (text/post/image/file/audio, etc.) this is pre-rendered human-readable text."`
}
func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
@@ -55,8 +55,10 @@ func processImMessageReceive(_ context.Context, _ event.APIClient, raw *event.Ra
}
msg := envelope.Event.Message
content := msg.Content
if msg.MessageType != "interactive" {
var content string
if msg.MessageType == "interactive" {
content = convertlib.ConvertInteractiveEventContent(msg.Content, msg.Mentions)
} else {
content = convertlib.ConvertBodyContent(msg.MessageType, &convertlib.ConvertContext{
RawContent: msg.Content,
MentionMap: convertlib.BuildMentionKeyMap(msg.Mentions),

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.55",
"version": "1.0.56",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -89,7 +89,7 @@ def main():
count = len(data.get("services", []))
print(f"fetch-meta: OK, {count} services from remote API", file=sys.stderr)
with open(OUT_PATH, "w") as fp:
with open(OUT_PATH, "w", encoding="utf-8", newline="\n") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.write("\n")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,993 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package convertlib
import (
"encoding/json"
"strings"
"testing"
)
func TestConvertInteractiveEventContent(t *testing.T) {
// invalid JSON → fallback
if got := ConvertInteractiveEventContent("not-json", nil); got != "[interactive card]" {
t.Fatalf("invalid JSON = %q, want [interactive card]", got)
}
// missing user_dsl → fallback
if got := ConvertInteractiveEventContent(`{"other":"field"}`, nil); got != "[interactive card]" {
t.Fatalf("missing user_dsl = %q, want [interactive card]", got)
}
// empty user_dsl → fallback
if got := ConvertInteractiveEventContent(`{"user_dsl":""}`, nil); got != "[interactive card]" {
t.Fatalf("empty user_dsl = %q, want [interactive card]", got)
}
// user_dsl that is not a string (wrong type) → fallback
if got := ConvertInteractiveEventContent(`{"user_dsl":123}`, nil); got != "[interactive card]" {
t.Fatalf("non-string user_dsl = %q, want [interactive card]", got)
}
// valid user-2 card → <card> output
userDsl := `{"schema":"2.0","header":{"title":{"tag":"plain_text","content":"Hello"}},"body":{"elements":[{"tag":"markdown","content":"world"}]}}`
rawContent := `{"user_dsl":"` + strings.ReplaceAll(userDsl, `"`, `\"`) + `"}`
got := ConvertInteractiveEventContent(rawContent, nil)
if !strings.HasPrefix(got, `<card title="Hello">`) {
t.Fatalf("valid card = %q, want prefix <card title=\"Hello\">", got)
}
if !strings.Contains(got, "world") {
t.Fatalf("valid card = %q, want to contain 'world'", got)
}
}
func makeMentionCard(mdContent string) string {
obj := map[string]interface{}{
"schema": "2.0",
"header": map[string]interface{}{
"title": map[string]interface{}{"tag": "plain_text", "content": "T"},
},
"body": map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{"tag": "markdown", "content": mdContent},
},
},
}
dslBytes, _ := json.Marshal(obj)
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
return string(raw)
}
func TestConvertInteractiveEventContentMentions(t *testing.T) {
mentions := []interface{}{
map[string]interface{}{
"key": "@_user_1",
"name": "test-user",
"id": map[string]interface{}{"open_id": "fake-uid-001"},
},
}
// quoted attrs: mention_key="key"
got := ConvertInteractiveEventContent(makeMentionCard(`hi <at mention_key="@_user_1">n</at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("quoted mention_key not resolved, got: %s", got)
}
// unquoted attrs (real Lark format): <at id=ou_xxx mention_key=@_user_1></at>
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_user_1></at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("unquoted mention_key not resolved, got: %s", got)
}
// mentions_key variant (unquoted)
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at mentions_key=@_user_1></at> done`), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("unquoted mentions_key not resolved, got: %s", got)
}
// degradation 1: no mention_key/mentions_key attr → fall back to @id (unquoted)
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001></at> done`), mentions)
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("no mention_key unquoted: expected @id fallback, got: %s", got)
}
// degradation 2: mention_key not found in mentions → fall back to @id
got = ConvertInteractiveEventContent(makeMentionCard(`hi <at id=fake-uid-001 mention_key=@_unknown></at> done`), mentions)
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("key not in mentions: expected @id fallback, got: %s", got)
}
// multi-mention: ids=id1,id2,id3 mentions_key=k1,,k3
// k1 hits → @name(id1), k2 empty → @id2 fallback, k3 not found → @id3 fallback
got = ConvertInteractiveEventContent(
makeMentionCard(`<at ids=fake-uid-001,fake-uid-002,fake-uid-003 mentions_key=@_user_1,,@_unknown></at>`),
mentions,
)
want := "@test-user(fake-uid-001)@fake-uid-002@fake-uid-003"
if !strings.Contains(got, want) {
t.Fatalf("multi-mention unquoted: want %q in output, got: %s", want, got)
}
}
func TestUserDslConverterSchema(t *testing.T) {
c := &userDslConverter{}
// user-2.ts: schema field present, header at root, body.elements
schema2 := cardObj{
"schema": "2.0",
"header": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Schema2 Title"},
"subtitle": cardObj{"tag": "plain_text", "content": "Sub"},
},
"body": cardObj{
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "body text"},
},
},
}
got := c.convert(schema2)
if got != "<card title=\"Schema2 Title\" subtitle=\"Sub\">\nbody text\n</card>" {
t.Fatalf("schema2 = %q", got)
}
// user-1.ts: no schema field, i18n_header.zh_cn, elements at root
schema1 := cardObj{
"i18n_header": cardObj{
"zh_cn": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Schema1 Title"},
},
},
"elements": []interface{}{
cardObj{"tag": "hr"},
},
}
got = c.convert(schema1)
if got != "<card title=\"Schema1 Title\">\n---\n</card>" {
t.Fatalf("schema1 = %q", got)
}
// user-1.ts: no schema, direct header (real Lark event format)
schema1Direct := cardObj{
"header": cardObj{
"title": cardObj{"tag": "plain_text", "content": "Direct Header Title"},
"subtitle": cardObj{"tag": "plain_text", "content": "Direct Sub"},
},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "direct body"},
},
}
got = c.convert(schema1Direct)
if got != "<card title=\"Direct Header Title\" subtitle=\"Direct Sub\">\ndirect body\n</card>" {
t.Fatalf("schema1 direct header = %q", got)
}
// no header, no elements → fallback
got = c.convert(cardObj{})
if got != "[interactive card]" {
t.Fatalf("empty card = %q, want [interactive card]", got)
}
// card with title only → valid (not "[interactive card]")
titleOnly := cardObj{
"schema": "2.0",
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "TitleOnly"}},
"body": cardObj{"elements": []interface{}{}},
}
got = c.convert(titleOnly)
if !strings.Contains(got, "TitleOnly") {
t.Fatalf("title-only card = %q, want to contain TitleOnly", got)
}
}
func TestUserDslConverterDispatch(t *testing.T) {
c := &userDslConverter{}
tests := []struct {
name string
elem cardObj
want string
contains string
}{
{
name: "plain_text",
elem: cardObj{"tag": "plain_text", "content": "hello"},
want: "hello",
},
{
name: "markdown",
elem: cardObj{"tag": "markdown", "content": "**bold**"},
want: "**bold**",
},
{
name: "hr",
elem: cardObj{"tag": "hr"},
want: "---",
},
{
name: "br",
elem: cardObj{"tag": "br"},
want: "\n",
},
{
name: "img with img_key",
elem: cardObj{
"tag": "img",
"img_key": "img_v3_abc",
"alt": cardObj{"tag": "plain_text", "content": "Banner"},
},
want: "🖼️ Banner(img_key:img_v3_abc)",
},
{
name: "img_combination",
elem: cardObj{
"tag": "img_combination",
"img_list": []interface{}{
cardObj{"img_key": "k1"},
cardObj{"img_key": "k2"},
},
},
want: "🖼️ 2 image(s)(keys:k1,k2)",
},
{
name: "button with behaviors default_url",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Open"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com"},
},
},
want: "[Open](https://example.com)",
},
{
name: "button disabled",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Nope"},
"disabled": true,
},
want: "[Nope ✗]",
},
{
name: "button no url",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Submit"},
},
want: "[Submit]",
},
{
name: "action wrapper (user-1 style)",
elem: cardObj{
"tag": "action",
"actions": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "A"}},
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "B"}},
},
},
want: "[A] [B]",
},
{
name: "overflow",
elem: cardObj{
"tag": "overflow",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Edit"}},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Delete"}},
},
},
want: "⋮ Edit, Delete",
},
{
name: "select_static no selection",
elem: cardObj{
"tag": "select_static",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
},
},
want: "{Option1 / Option2 ▼}",
},
{
name: "select_static with initial_option",
elem: cardObj{
"tag": "select_static",
"initial_option": "2",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option1"}, "value": "1"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Option2"}, "value": "2"},
},
},
want: "{Option1 / ✓Option2}",
},
{
name: "multi_select_static with selected_values",
elem: cardObj{
"tag": "multi_select_static",
"selected_values": []interface{}{"A"},
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptA"}, "value": "A"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "OptB"}, "value": "B"},
},
},
want: "{✓OptA / OptB}(multi)",
},
{
name: "select_person no options no selection shows placeholder",
elem: cardObj{
"tag": "select_person",
"placeholder": cardObj{"tag": "plain_text", "content": "请选择"},
},
want: "{请选择 ▼}",
},
{
name: "select_person with initial_option synthesizes from ID",
elem: cardObj{
"tag": "select_person",
"initial_option": "fake-open-id-001",
},
want: "{✓fake-open-id-001}",
},
{
name: "multi_select_person with selected_values shows IDs and multi",
elem: cardObj{
"tag": "multi_select_person",
"selected_values": []interface{}{"fake-open-id-001", "fake-open-id-002"},
},
want: "{✓fake-open-id-001 / ✓fake-open-id-002}(multi)",
},
{
name: "multi_select_person no selection shows placeholder",
elem: cardObj{
"tag": "multi_select_person",
"placeholder": cardObj{"tag": "plain_text", "content": "添加人员"},
},
want: "{添加人员 ▼}(multi)",
},
{
name: "input with default_value",
elem: cardObj{
"tag": "input",
"label": cardObj{"tag": "plain_text", "content": "Reason"},
"default_value": "prefilled",
},
want: "Reason: prefilled___",
},
{
name: "input with placeholder",
elem: cardObj{
"tag": "input",
"placeholder": cardObj{"tag": "plain_text", "content": "Type here"},
},
want: "Type here_____",
},
{
name: "date_picker with initial_date",
elem: cardObj{
"tag": "date_picker",
"initial_date": "2026-01-01",
},
want: "📅 2026-01-01",
},
{
name: "date_picker placeholder",
elem: cardObj{
"tag": "date_picker",
"placeholder": cardObj{"tag": "plain_text", "content": "Pick date"},
},
want: "📅 Pick date",
},
{
name: "picker_time with initial_time",
elem: cardObj{
"tag": "picker_time",
"initial_time": "14:30",
},
want: "🕐 14:30",
},
{
name: "checker unchecked",
elem: cardObj{
"tag": "checker",
"text": cardObj{"tag": "plain_text", "content": "Task A"},
},
want: "[ ] Task A",
},
{
name: "checker checked",
elem: cardObj{
"tag": "checker",
"checked": true,
"text": cardObj{"tag": "plain_text", "content": "Task B"},
},
want: "[x] Task B",
},
{
name: "chart with chart_spec",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"title": cardObj{"text": "Sales"},
"type": "bar",
"xField": "month",
"yField": "value",
"data": cardObj{"values": []interface{}{
cardObj{"month": "Jan", "value": float64(10)},
cardObj{"month": "Feb", "value": float64(20)},
}},
},
},
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
},
{
name: "chart with compound xField array",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"title": cardObj{"text": "Sales"},
"type": "bar",
"xField": []interface{}{"month", "category"},
"yField": "value",
"data": cardObj{"values": []interface{}{
cardObj{"month": "Jan", "category": "A", "value": float64(10)},
cardObj{"month": "Feb", "category": "B", "value": float64(20)},
}},
},
},
want: "📊 Sales (Bar chart)\nSummary: Jan:10, Feb:20",
},
{
name: "chart no custom title uses type name",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"type": "pie",
"categoryField": "label",
"valueField": "val",
"data": cardObj{"values": []interface{}{
cardObj{"label": "A", "val": float64(1)},
}},
},
},
want: "📊 Pie chart\nSummary: A:1",
},
{
name: "chart vchart array data format",
elem: cardObj{
"tag": "chart",
"chart_spec": cardObj{
"type": "bar",
"xField": "x",
"yField": "y",
"data": []interface{}{
cardObj{"id": "s1", "values": []interface{}{
cardObj{"x": "Jan", "y": float64(5)},
}},
cardObj{"id": "s2", "values": []interface{}{
cardObj{"x": "Feb", "y": float64(8)},
}},
},
},
},
want: "📊 Bar chart\nSummary: Jan:5, Feb:8",
},
{
name: "text_tag",
elem: cardObj{
"tag": "text_tag",
"text": cardObj{"tag": "plain_text", "content": "新功能"},
},
want: "「新功能」",
},
{
name: "avatar with user_id",
elem: cardObj{"tag": "avatar", "user_id": "fake-open-id-001"},
want: "👤(id:fake-open-id-001)",
},
{
name: "avatar no user_id",
elem: cardObj{"tag": "avatar"},
want: "👤",
},
{
name: "select_img no selection",
elem: cardObj{
"tag": "select_img",
"options": []interface{}{
cardObj{"value": "v1", "img_key": "img_k1"},
cardObj{"value": "v2", "img_key": "img_k2"},
},
},
want: "{🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
},
{
name: "select_img with selected",
elem: cardObj{
"tag": "select_img",
"selected_values": []interface{}{"v1"},
"options": []interface{}{
cardObj{"value": "v1", "img_key": "img_k1"},
cardObj{"value": "v2", "img_key": "img_k2"},
},
},
want: "{✓🖼️ Image 1(v1)(img_key:img_k1) / 🖼️ Image 2(v2)(img_key:img_k2)}",
},
{
name: "repeat delegates to elements",
elem: cardObj{
"tag": "repeat",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "item A"},
cardObj{"tag": "markdown", "content": "item B"},
},
},
want: "item A\nitem B",
},
{
name: "audio with file_key",
elem: cardObj{"tag": "audio", "file_key": "file_abc123"},
want: "🎵 Audio(key:file_abc123)",
},
{
name: "audio fallback audio_id",
elem: cardObj{"tag": "audio", "audio_id": "audio_xyz"},
want: "🎵 Audio(key:audio_xyz)",
},
{
name: "video with file_key",
elem: cardObj{"tag": "video", "file_key": "video_abc"},
want: "🎬 Video(key:video_abc)",
},
{
name: "custom_icon returns empty",
elem: cardObj{"tag": "custom_icon", "img_key": "some_key"},
want: "",
},
{
name: "standard_icon returns empty",
elem: cardObj{"tag": "standard_icon", "token": "alarm_outlined"},
want: "",
},
{
name: "button disabled with disabled_tips",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Submit"},
"disabled": true,
"disabled_tips": cardObj{"tag": "plain_text", "content": "Not allowed"},
},
want: "[Submit ✗](tips:\"Not allowed\")",
},
{
name: "button with confirm",
elem: cardObj{
"tag": "button",
"text": cardObj{"tag": "plain_text", "content": "Delete"},
"confirm": cardObj{
"title": cardObj{"tag": "plain_text", "content": "确认"},
"text": cardObj{"tag": "plain_text", "content": "不可撤销"},
},
},
want: "[Delete](confirm:\"确认: 不可撤销\")",
},
{
name: "overflow with url",
elem: cardObj{
"tag": "overflow",
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "Open"}, "url": "https://example.com"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Copy"}, "value": "copy"},
},
},
want: "⋮ [Open](https://example.com), Copy(copy)",
},
{
name: "select_static with initial_index",
elem: cardObj{
"tag": "select_static",
"initial_index": float64(1),
"options": []interface{}{
cardObj{"text": cardObj{"tag": "plain_text", "content": "First"}, "value": "a"},
cardObj{"text": cardObj{"tag": "plain_text", "content": "Second"}, "value": "b"},
},
},
want: "{First / ✓Second}",
},
{
name: "div text with notation size",
elem: cardObj{
"tag": "div",
"text": cardObj{
"tag": "plain_text",
"content": "小字注释",
"text_size": "notation",
},
},
want: "📝 小字注释",
},
{
name: "form",
elem: cardObj{
"tag": "form",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "fill this"},
},
},
want: "<form>\nfill this\n</form>",
},
{
name: "collapsible_panel collapsed",
elem: cardObj{
"tag": "collapsible_panel",
"expanded": false,
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "inner"},
},
},
want: "▶ Details\n inner\n▲",
},
{
name: "collapsible_panel expanded",
elem: cardObj{
"tag": "collapsible_panel",
"expanded": true,
"header": cardObj{"title": cardObj{"tag": "plain_text", "content": "Details"}},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "inner"},
},
},
want: "▼ Details\n inner\n▲",
},
{
name: "interactive_container with behaviors",
elem: cardObj{
"tag": "interactive_container",
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com"},
},
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "Click here"},
},
},
want: "<clickable url=\"https://example.com\">\nClick here\n</clickable>",
},
{
name: "interactive_container no url",
elem: cardObj{
"tag": "interactive_container",
"elements": []interface{}{
cardObj{"tag": "markdown", "content": "No link"},
},
},
want: "<clickable>\nNo link\n</clickable>",
},
{
name: "column_set with buttons → space-joined",
elem: cardObj{
"tag": "column_set",
"columns": []interface{}{
cardObj{"tag": "column", "elements": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "X"}},
}},
cardObj{"tag": "column", "elements": []interface{}{
cardObj{"tag": "button", "text": cardObj{"tag": "plain_text", "content": "Y"}},
}},
},
},
want: "[X] [Y]",
},
{
name: "person",
elem: cardObj{"tag": "person", "user_id": "fake-open-id-002"},
want: "fake-open-id-002",
},
{
name: "unknown tag fallback to content",
elem: cardObj{"tag": "mystery", "content": "mystery content"},
want: "mystery content",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := c.convertElement(tt.elem, 0)
if tt.contains != "" {
if !strings.Contains(got, tt.contains) {
t.Fatalf("convertElement(%s) = %q, want to contain %q", tt.name, got, tt.contains)
}
return
}
if got != tt.want {
t.Fatalf("convertElement(%s) = %q, want %q", tt.name, got, tt.want)
}
})
}
}
func TestUserDslExtractButtonURL(t *testing.T) {
c := &userDslConverter{}
// direct url field wins first
got := c.extractButtonURL(cardObj{
"url": "https://example.com/direct",
"multi_url": cardObj{"url": "https://example.com/multi"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/direct" {
t.Fatalf("direct url = %q, want https://example.com/direct", got)
}
// multi_url.url when no direct url
got = c.extractButtonURL(cardObj{
"multi_url": cardObj{"url": "https://example.com/multi"},
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/multi" {
t.Fatalf("multi_url = %q, want https://example.com/multi", got)
}
// behaviors default_url as last resort
got = c.extractButtonURL(cardObj{
"behaviors": []interface{}{
cardObj{"type": "open_url", "default_url": "https://example.com/behavior"},
},
})
if got != "https://example.com/behavior" {
t.Fatalf("behaviors = %q, want https://example.com/behavior", got)
}
// non-open_url behavior is ignored
got = c.extractButtonURL(cardObj{
"behaviors": []interface{}{
cardObj{"type": "callback", "default_url": "https://example.com/callback"},
},
})
if got != "" {
t.Fatalf("non-open_url = %q, want empty", got)
}
// no url anywhere → empty
got = c.extractButtonURL(cardObj{"text": cardObj{"content": "No URL"}})
if got != "" {
t.Fatalf("no url = %q, want empty", got)
}
}
func TestUserDslExtractTableCellValue(t *testing.T) {
c := &userDslConverter{}
// nil
if got := c.extractUserDslTableCellValue(nil); got != "" {
t.Fatalf("nil = %q, want empty", got)
}
// string
if got := c.extractUserDslTableCellValue("hello"); got != "hello" {
t.Fatalf("string = %q, want 'hello'", got)
}
// float64 integer
if got := c.extractUserDslTableCellValue(float64(42)); got != "42" {
t.Fatalf("int float = %q, want '42'", got)
}
// float64 decimal
if got := c.extractUserDslTableCellValue(float64(3.14)); got != "3.14" {
t.Fatalf("float = %q, want '3.14'", got)
}
// []interface{} with text tags → 「text」 format
got := c.extractUserDslTableCellValue([]interface{}{
cardObj{"text": "S2", "color": "blue"},
cardObj{"text": "M1", "color": "red"},
})
if got != "「S2」 「M1」" {
t.Fatalf("tag array = %q, want '「S2」 「M1」'", got)
}
// cardObj with content field
got = c.extractUserDslTableCellValue(cardObj{"content": "cell content"})
if got != "cell content" {
t.Fatalf("cardObj with content = %q, want 'cell content'", got)
}
}
func TestUserDslConvertTable(t *testing.T) {
c := &userDslConverter{}
got := c.convertTable(cardObj{
"columns": []interface{}{
cardObj{"display_name": "客户名称", "name": "customer_name"},
cardObj{"display_name": "规模", "name": "scale"},
cardObj{"display_name": "金额", "name": "arr"},
},
"rows": []interface{}{
cardObj{
"customer_name": "飞书科技",
"scale": []interface{}{cardObj{"text": "S2", "color": "blue"}},
"arr": float64(16800),
},
},
})
want := "| 客户名称 | 规模 | 金额 |\n|------|------|------|\n| 飞书科技 | 「S2」 | 16800 |"
if got != want {
t.Fatalf("convertTable() = %q, want %q", got, want)
}
// no columns → empty
if got := c.convertTable(cardObj{}); got != "" {
t.Fatalf("no columns = %q, want empty", got)
}
}
func TestLarkMdMentionResolution(t *testing.T) {
mentions := []interface{}{
map[string]interface{}{
"key": "@_user_1",
"name": "test-user",
"id": map[string]interface{}{"open_id": "fake-uid-001"},
},
}
// lark_md in div.text — the real Lark event format (C01 case)
card := map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": "Hello <at id=fake-uid-001></at> check this.",
},
},
},
}
dslBytes, _ := json.Marshal(card)
raw, _ := json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got := ConvertInteractiveEventContent(string(raw), mentions)
if strings.Contains(got, "<at") {
t.Fatalf("div.text lark_md: raw <at> tag not resolved, got: %s", got)
}
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("div.text lark_md: @id not in output, got: %s", got)
}
// lark_md in note.elements (C02 case)
card = map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "note",
"elements": []interface{}{
map[string]interface{}{
"tag": "lark_md",
"content": "Note: <at id=fake-uid-001></at> check.",
},
},
},
},
}
dslBytes, _ = json.Marshal(card)
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got = ConvertInteractiveEventContent(string(raw), mentions)
if strings.Contains(got, "<at") {
t.Fatalf("note lark_md: raw <at> tag not resolved, got: %s", got)
}
if !strings.Contains(got, "@fake-uid-001") {
t.Fatalf("note lark_md: @id not in output, got: %s", got)
}
// mention_key resolution via mentions map
card = map[string]interface{}{
"elements": []interface{}{
map[string]interface{}{
"tag": "div",
"text": map[string]interface{}{
"tag": "lark_md",
"content": `Hi <at mention_key="@_user_1">n</at> done.`,
},
},
},
}
dslBytes, _ = json.Marshal(card)
raw, _ = json.Marshal(map[string]interface{}{"user_dsl": string(dslBytes)})
got = ConvertInteractiveEventContent(string(raw), mentions)
if !strings.Contains(got, "@test-user(fake-uid-001)") {
t.Fatalf("div.text lark_md mention_key: want @test-user(fake-uid-001), got: %s", got)
}
}
func TestConvertUserDslCardEndToEnd(t *testing.T) {
// user-2.ts format — matches structure of docs/user-dsl/user-example-2.json
schema2JSON := `{
"schema": "2.0",
"header": {
"title": {"tag": "plain_text", "content": "飞书卡片组件展示"},
"template": "blue"
},
"body": {
"elements": [
{"tag": "markdown", "content": "### 基础文本"},
{"tag": "hr"},
{
"tag": "img",
"img_key": "img_v3_02122_abc",
"alt": {"tag": "plain_text", "content": "示例图片"}
},
{
"tag": "button",
"text": {"tag": "plain_text", "content": "主要按钮"},
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
},
{
"tag": "table",
"columns": [
{"display_name": "名称", "name": "name"},
{"display_name": "数值", "name": "value"}
],
"rows": [
{"name": "项目A", "value": 100},
{"name": "项目B", "value": 200}
]
}
]
}
}`
got := convertUserDslCard(schema2JSON, nil)
if !strings.HasPrefix(got, `<card title="飞书卡片组件展示">`) {
t.Fatalf("e2e schema2: missing card title prefix, got: %s", got)
}
if !strings.Contains(got, "### 基础文本") {
t.Fatal("e2e schema2: missing markdown content")
}
if !strings.Contains(got, "---") {
t.Fatal("e2e schema2: missing hr")
}
if !strings.Contains(got, "🖼️ 示例图片(img_key:img_v3_02122_abc)") {
t.Fatalf("e2e schema2: missing image, got: %s", got)
}
if !strings.Contains(got, "[主要按钮](https://example.com)") {
t.Fatalf("e2e schema2: missing button, got: %s", got)
}
if !strings.Contains(got, "| 名称 | 数值 |") {
t.Fatal("e2e schema2: missing table header")
}
if !strings.Contains(got, "| 项目A | 100 |") {
t.Fatalf("e2e schema2: missing table row, got: %s", got)
}
if !strings.HasSuffix(got, "</card>") {
t.Fatalf("e2e schema2: missing </card> suffix, got: %s", got)
}
// user-1.ts format
schema1JSON := `{
"i18n_header": {
"zh_cn": {
"title": {"tag": "plain_text", "content": "Schema1 卡片"},
"template": "blue"
}
},
"elements": [
{"tag": "markdown", "content": "Hello **World**"},
{
"tag": "action",
"actions": [
{
"tag": "button",
"text": {"tag": "plain_text", "content": "跳转"},
"behaviors": [{"type": "open_url", "default_url": "https://example.com"}]
}
]
}
]
}`
got = convertUserDslCard(schema1JSON, nil)
if !strings.HasPrefix(got, `<card title="Schema1 卡片">`) {
t.Fatalf("e2e schema1: missing card title, got: %s", got)
}
if !strings.Contains(got, "Hello **World**") {
t.Fatal("e2e schema1: missing markdown")
}
if !strings.Contains(got, "[跳转](https://example.com)") {
t.Fatalf("e2e schema1: missing button, got: %s", got)
}
}

View File

@@ -122,7 +122,7 @@ func TestExtractPostBlocksText(t *testing.T) {
}
got := extractPostBlocksText(blocks)
want := "hello @Alice [docs](https://example.com)\n[Image: img_123]"
want := "hello @Alice [docs](https://example.com)\n![Image](img_123)"
if got != want {
t.Fatalf("extractPostBlocksText() = %q, want %q", got, want)
}

View File

@@ -39,16 +39,16 @@ func (postConverter) Convert(ctx *ConvertContext) string {
if title, _ := body["title"].(string); title != "" {
parts = append(parts, title)
}
if blocks, _ := body["content"].([]interface{}); len(blocks) > 0 {
for _, para := range blocks {
elems, _ := para.([]interface{})
var line strings.Builder
for _, el := range elems {
elem, _ := el.(map[string]interface{})
line.WriteString(renderPostElem(elem))
}
parts = append(parts, line.String())
// Prefer content_v2 blocks; fallback to content blocks
blocks := selectContentBlocks(body)
for _, para := range blocks {
elems, _ := para.([]interface{})
var line strings.Builder
for _, el := range elems {
elem, _ := el.(map[string]interface{})
line.WriteString(renderPostElem(elem))
}
parts = append(parts, line.String())
}
result := strings.TrimSpace(strings.Join(parts, "\n"))
@@ -58,6 +58,17 @@ func (postConverter) Convert(ctx *ConvertContext) string {
return ResolveMentionKeys(result, ctx.MentionMap)
}
// selectContentBlocks returns content_v2 blocks when present and non-empty;
// otherwise falls back to content blocks. This implements the content_v2
// priority rule for post messages.
func selectContentBlocks(body map[string]interface{}) []interface{} {
if v2, ok := body["content_v2"].([]interface{}); ok && len(v2) > 0 {
return v2
}
blocks, _ := body["content"].([]interface{})
return blocks
}
func unwrapPostLocale(parsed map[string]interface{}) map[string]interface{} {
if _, ok := parsed["content"]; ok {
return parsed
@@ -114,10 +125,14 @@ func renderPostElem(el map[string]interface{}) string {
var rendered string
switch {
case userId == "@_all" || userId == "all":
rendered = "@all"
rendered = `<at user_id="all"></at>`
default:
if name, _ := el["user_name"].(string); name != "" {
rendered = "@" + name
if userId != "" && strings.HasPrefix(userId, "ou") {
rendered = fmt.Sprintf(`<at user_id="%s">%s</at>`, userId, name)
} else {
rendered = "@" + name
}
} else {
rendered = "@" + userId
}
@@ -138,7 +153,7 @@ func renderPostElem(el map[string]interface{}) string {
case "img":
key, _ := el["image_key"].(string)
if key != "" {
return fmt.Sprintf("[Image: %s]", key)
return fmt.Sprintf("![Image](%s)", key)
}
return "[Image]"
case "media":

View File

@@ -93,9 +93,13 @@ func TestRenderPostElem(t *testing.T) {
}{
{name: "text", el: map[string]interface{}{"tag": "text", "text": "hello"}, want: "hello"},
{name: "link", el: map[string]interface{}{"tag": "a", "text": "doc", "href": "https://example.com"}, want: "[doc](https://example.com)"},
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: "@all"},
{name: "mention user", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: "[Image: img_123]"},
{name: "mention all", el: map[string]interface{}{"tag": "at", "user_id": "@_all"}, want: `<at user_id="all"></at>`},
{name: "mention user with id", el: map[string]interface{}{"tag": "at", "user_id": "ou_user_1", "user_name": "Alice"}, want: `<at user_id="ou_user_1">Alice</at>`},
{name: "mention user name only", el: map[string]interface{}{"tag": "at", "user_name": "Alice"}, want: "@Alice"},
{name: "mention user id only", el: map[string]interface{}{"tag": "at", "user_id": "@_user_1"}, want: "@@_user_1"},
{name: "image", el: map[string]interface{}{"tag": "img", "image_key": "img_123"}, want: "![Image](img_123)"},
{name: "image no key", el: map[string]interface{}{"tag": "img"}, want: "[Image]"},
{name: "md text", el: map[string]interface{}{"tag": "md", "text": "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}, want: "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"},
{name: "media", el: map[string]interface{}{"tag": "media", "file_key": "file_123"}, want: "[Media: file_123]"},
{name: "code block", el: map[string]interface{}{"tag": "code_block", "language": "go", "text": "fmt.Println(1)"}, want: "\n```go\nfmt.Println(1)\n```\n"},
{name: "hr", el: map[string]interface{}{"tag": "hr"}, want: "\n---\n"},
@@ -144,3 +148,87 @@ func TestRenderPostElemEmotionStyleMd(t *testing.T) {
})
}
}
func TestSelectContentBlocks(t *testing.T) {
tests := []struct {
name string
body map[string]interface{}
want int
}{
{
name: "content_v2 present and non-empty",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": []interface{}{[]interface{}{map[string]interface{}{"tag": "md", "text": "new"}}},
},
want: 1,
},
{
name: "content_v2 empty array",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": []interface{}{},
},
want: 1,
},
{
name: "content_v2 nil",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
},
want: 1,
},
{
name: "content_v2 wrong type",
body: map[string]interface{}{
"content": []interface{}{[]interface{}{map[string]interface{}{"tag": "text", "text": "old"}}},
"content_v2": "not_an_array",
},
want: 1,
},
{
name: "both missing",
body: map[string]interface{}{},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := selectContentBlocks(tt.body)
if len(got) != tt.want {
t.Fatalf("selectContentBlocks() len = %d, want %d", len(got), tt.want)
}
})
}
}
func TestPostConverterConvertContentV2(t *testing.T) {
// AC-M1-H1: content_v2 present → use content_v2 blocks (md passthrough)
ctx := &ConvertContext{
RawContent: `{"content_v2":[[{"tag":"md","text":"##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"}]],"content":[[{"tag":"text","text":"old path"}]]}`,
}
want := "##### 标题\n\n<at user_id=\"ou_xxx\">Alice</at> 你好"
if got := (postConverter{}).Convert(ctx); got != want {
t.Fatalf("postConverter.Convert(content_v2) = %q, want %q", got, want)
}
// AC-M1-H2: no content_v2 → use content blocks with new at/img format
ctx2 := &ConvertContext{
RawContent: `{"content":[[{"tag":"at","user_id":"ou_xxx","user_name":"Bob"},{"tag":"text","text":" "},{"tag":"img","image_key":"img_123"}]]}`,
Mentions: []interface{}{map[string]interface{}{"key": "ou_xxx", "id": "ou_bob", "name": "Bob"}},
}
want2 := `<at user_id="ou_xxx">Bob</at> ![Image](img_123)`
if got := (postConverter{}).Convert(ctx2); got != want2 {
t.Fatalf("postConverter.Convert(content) = %q, want %q", got, want2)
}
// AC-M1-E1: content_v2 empty → fallback to content
ctx3 := &ConvertContext{
RawContent: `{"content_v2":[],"content":[[{"tag":"text","text":"fallback path"}]]}`,
}
want3 := "fallback path"
if got := (postConverter{}).Convert(ctx3); got != want3 {
t.Fatalf("postConverter.Convert(empty content_v2) = %q, want %q", got, want3)
}
}

View File

@@ -0,0 +1,385 @@
// 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/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// batchCreateKR represents a key result in the batch create input.
type batchCreateKR struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
}
// batchCreateObjective represents an objective in the batch create input.
type batchCreateObjective struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
KRs []batchCreateKR `json:"krs,omitempty"`
}
// createdObjective tracks a created objective and its KR IDs for output.
// KRs are automatically deleted by the backend when the objective is deleted (no need to delete them separately during rollback).
type createdObjective struct {
ObjectiveID string
KRIDs []string // for output response only, not used in rollback
}
// parseBatchCreateInput parses and validates the JSON input.
func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
var objectives []batchCreateObjective
if err := json.Unmarshal([]byte(input), &objectives); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must be valid JSON array: %s", err).WithParam("--input").WithCause(err)
}
if len(objectives) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--input must contain at least one objective").WithParam("--input")
}
for i, obj := range objectives {
if strings.TrimSpace(obj.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].text is required and cannot be empty", i).WithParam("--input")
}
for j, kr := range obj.KRs {
if strings.TrimSpace(kr.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].krs[%d].text is required and cannot be empty", i, j).WithParam("--input")
}
}
}
return objectives, nil
}
// buildContentBlock converts text and mentions to a ContentBlock.
func buildContentBlock(text string, mentions []string) *ContentBlock {
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
// Add text element
textElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: &text,
},
}
elements = append(elements, textElem)
// Add mention elements
for _, mention := range mentions {
mentionElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: &mention,
},
}
elements = append(elements, mentionElem)
}
return &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: elements,
},
},
},
}
}
// createObjective calls the API to create an objective.
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
content := buildContentBlock(obj.Text, obj.Mention)
body := map[string]interface{}{
"content": content,
}
queryParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to create objective")
}
objectiveID, ok := data["objective_id"].(string)
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "create objective response missing objective_id")
}
return objectiveID, nil
}
// createKR calls the API to create a key result.
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
content := buildContentBlock(kr.Text, kr.Mention)
body := map[string]interface{}{
"content": content,
}
queryParams := map[string]interface{}{
"objective_id": objectiveID,
"user_id_type": userIDType,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to create key result")
}
krID, ok := data["key_result_id"].(string)
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "create key result response missing key_result_id")
}
return krID, nil
}
// deleteObjective deletes an objective (used for rollback).
func deleteObjective(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) error {
queryParams := map[string]interface{}{
"objective_id": objectiveID,
"yes": true,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s", objectiveID)
_, err := runtime.CallAPITyped("DELETE", path, queryParams, nil)
if err != nil {
return wrapOkrNetworkErr(err, "failed to delete objective %s during rollback", objectiveID)
}
return nil
}
// rollback deletes created objectives in reverse order.
// KRs are automatically deleted by the backend when the objective is deleted.
func rollback(ctx context.Context, runtime *common.RuntimeContext, created []createdObjective) []error {
var errsList []error
// Iterate in reverse order
for i := len(created) - 1; i >= 0; i-- {
obj := created[i]
// Delete the objective (backend automatically deletes its KRs)
if err := deleteObjective(ctx, runtime, obj.ObjectiveID); err != nil {
//nolint:forbidigo // intermediate wrap for rollback error collection; final error is typed via buildRollbackError
errsList = append(errsList, fmt.Errorf("objective %s: %w", obj.ObjectiveID, err))
}
// Rate limiting between deletions
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
}
return errsList
}
// OKRBatchCreate batch creates objectives and their key results.
var OKRBatchCreate = common.Shortcut{
Service: "okr",
Command: "+batch-create",
Description: "Batch create OKR objectives and key results with rollback on failure",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
{Name: "input", Desc: "JSON array of objectives: [{\"text\":\"...\",\"mention\":[\"...\"],\"krs\":[{\"text\":\"...\",\"mention\":[\"...\"]}]}]", Input: []string{common.File, common.Stdin}, Required: true},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
input := runtime.Str("input")
if err := common.RejectDangerousCharsTyped("--input", input); err != nil {
return err
}
if _, err := parseBatchCreateInput(input); err != nil {
return err
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
objectives, _ := parseBatchCreateInput(runtime.Str("input"))
apis := common.NewDryRunAPI()
for i, obj := range objectives {
// Objective creation
objContent := buildContentBlock(obj.Text, obj.Mention)
objBody := map[string]interface{}{
"content": objContent,
}
objParams := map[string]interface{}{
"cycle_id": cycleID,
"user_id_type": userIDType,
}
objPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
POST(objPath).
Params(objParams).
Body(objBody).
Desc(fmt.Sprintf("Create objective[%d]: %s", i, obj.Text))
// KR creations
for j, kr := range obj.KRs {
krContent := buildContentBlock(kr.Text, kr.Mention)
krBody := map[string]interface{}{
"content": krContent,
}
krParams := map[string]interface{}{
"objective_id": "<objective_id_from_previous_call>",
"user_id_type": userIDType,
}
krPath := "/open-apis/okr/v2/objectives/<objective_id>/key_results"
apis = apis.
POST(krPath).
Params(krParams).
Body(krBody).
Desc(fmt.Sprintf("Create objective[%d].krs[%d]: %s", i, j, kr.Text))
}
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id")
userIDType := runtime.Str("user-id-type")
objectives, err := parseBatchCreateInput(runtime.Str("input"))
if err != nil {
return err
}
var created []createdObjective
for i, obj := range objectives {
// Rate limiting between objectives
if i > 0 {
time.Sleep(500 * time.Millisecond)
}
// Create objective
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, obj)
if err != nil {
if len(created) == 0 {
return err
}
rollbackErrs := rollback(ctx, runtime, created)
return buildRollbackError(err, rollbackErrs, created)
}
createdObj := createdObjective{
ObjectiveID: objectiveID,
}
// Create KRs
for j, kr := range obj.KRs {
// Rate limiting between KRs
if j > 0 {
time.Sleep(500 * time.Millisecond)
}
krID, err := createKR(ctx, runtime, objectiveID, userIDType, kr)
if err != nil {
created = append(created, createdObj)
rollbackErrs := rollback(ctx, runtime, created)
return buildRollbackError(err, rollbackErrs, created)
}
createdObj.KRIDs = append(createdObj.KRIDs, krID)
}
created = append(created, createdObj)
}
// Build response
respCreated := make([]map[string]interface{}, 0, len(created))
for _, obj := range created {
respCreated = append(respCreated, map[string]interface{}{
"objective_id": obj.ObjectiveID,
"krs": obj.KRIDs,
})
}
result := map[string]interface{}{
"ok": true,
"data": map[string]interface{}{"created": respCreated},
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully created %d objective(s)\n", len(created))
for i, obj := range created {
fmt.Fprintf(w, "Objective[%d] ID: %s (%d KR(s))\n", i, obj.ObjectiveID, len(obj.KRIDs))
for j, krID := range obj.KRIDs {
fmt.Fprintf(w, " KR[%d] ID: %s\n", j, krID)
}
}
})
return nil
},
}
// buildRollbackError constructs an error that includes both the original failure
// and any rollback failures, with a list of residual IDs that could not be cleaned up.
// KRs are automatically deleted by the backend when the objective is deleted, so we only
// need to track objective IDs for residual cleanup.
func buildRollbackError(originalErr error, rollbackErrs []error, created []createdObjective) error {
var residualIDs []string
// Only collect residual IDs when rollback had failures
// If rollback succeeded (len(rollbackErrs) == 0), all objectives were deleted
if len(rollbackErrs) > 0 {
for _, obj := range created {
residualIDs = append(residualIDs, fmt.Sprintf("objective:%s", obj.ObjectiveID))
}
}
msg := fmt.Sprintf("batch create failed, rolling back: %v", originalErr)
if len(rollbackErrs) > 0 {
var rollbackMsgs []string
for _, e := range rollbackErrs {
rollbackMsgs = append(rollbackMsgs, e.Error())
}
msg += fmt.Sprintf("; rollback also had %d failure(s): %s", len(rollbackErrs), strings.Join(rollbackMsgs, "; "))
}
if len(residualIDs) > 0 {
msg += fmt.Sprintf("; residual objectives that may need manual cleanup (KRs auto-deleted with objective): %s", strings.Join(residualIDs, ", "))
}
// Preserve the original error's type information if it's already a typed error
if prob, ok := errs.ProblemOf(originalErr); ok {
switch prob.Category {
case errs.CategoryAPI:
return errs.NewAPIError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryNetwork:
return errs.NewNetworkError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryValidation:
return errs.NewValidationError(prob.Subtype, "%s", msg).WithCause(originalErr)
case errs.CategoryInternal:
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
default:
return errs.NewInternalError(prob.Subtype, "%s", msg).WithCause(originalErr)
}
}
return errs.NewInternalError(errs.SubtypeUnknown, "%s", msg).WithCause(originalErr)
}

View File

@@ -0,0 +1,593 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-batch-create",
AppSecret: "secret-okr-batch-create",
Brand: core.BrandFeishu,
}
}
func runBatchCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRBatchCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
const validBatchCreateInput = `[
{"text":"Objective 1","mention":["ou_123"],"krs":[{"text":"KR 1.1","mention":["ou_456"]}]},
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
]`
// --- Validate tests ---
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--input", validBatchCreateInput,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "abc",
"--input", validBatchCreateInput,
})
if err == nil {
t.Fatal("expected error for invalid --cycle-id")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--cycle-id" {
t.Fatalf("expected param --cycle-id, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_MissingInput(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "input") {
t.Fatalf("expected --input required error, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidInputJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --input JSON")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_EmptyInputArray(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", "[]",
})
if err == nil {
t.Fatal("expected error for empty --input array")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_EmptyObjectiveText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for empty objective text")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "objective[0].text") {
t.Fatalf("expected error to mention objective[0].text, got: %v", err)
}
}
func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":""}]}]`,
})
if err == nil {
t.Fatal("expected error for empty KR text")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--input" {
t.Fatalf("expected param --input, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "objective[0].krs[0].text") {
t.Fatalf("expected error to mention objective[0].krs[0].text, got: %v", err)
}
}
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
"--user-id-type", "invalid",
})
if err == nil {
t.Fatal("expected error for invalid --user-id-type")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--user-id-type" {
t.Fatalf("expected param --user-id-type, got %q", validationErr.Param)
}
}
func TestBatchCreateValidate_Valid(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "200",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "101",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/101/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "201",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/101/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "202",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- DryRun tests ---
func TestBatchCreateDryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", validBatchCreateInput,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objective creation API path, got: %s", output)
}
if !strings.Contains(output, "POST") {
t.Fatalf("dry-run output should contain POST method, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/") || !strings.Contains(output, "/key_results") {
t.Fatalf("dry-run output should contain KR creation API path, got: %s", output)
}
// Verify content is in the body
if !strings.Contains(output, "Objective 1") {
t.Fatalf("dry-run output should contain objective text, got: %s", output)
}
if !strings.Contains(output, "KR 1.1") {
t.Fatalf("dry-run output should contain KR text, got: %s", output)
}
}
// --- Execute tests ---
func TestBatchCreateExecute_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"key_result_id": "200",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
ok, _ := data["ok"].(bool)
if !ok {
t.Fatal("expected ok=true in output")
}
dataField, _ := data["data"].(map[string]interface{})
created, _ := dataField["created"].([]interface{})
if len(created) != 1 {
t.Fatalf("expected 1 created objective, got %d", len(created))
}
obj, _ := created[0].(map[string]interface{})
if obj["objective_id"] != "100" {
t.Fatalf("expected objective_id=100, got %v", obj["objective_id"])
}
krs, _ := obj["krs"].([]interface{})
if len(krs) != 1 || krs[0] != "200" {
t.Fatalf("expected krs=[200], got %v", krs)
}
}
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1"}]`,
})
if err == nil {
t.Fatal("expected error for API failure")
}
// Should be a typed error from the API
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != "api" {
t.Fatalf("expected api category, got %q", prob.Category)
}
}
func TestBatchCreateExecute_APIErrorOnKR_TriggersRollback(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
// First objective creation succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
// KR creation fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
// Rollback: delete the created objective
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/okr/v2/objectives/100",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for KR creation failure")
}
// Error should mention rollback
if !strings.Contains(err.Error(), "rolling back") && !strings.Contains(err.Error(), "rollback") {
t.Fatalf("expected error to mention rollback, got: %v", err)
}
// Assert typed error metadata
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected api category (preserved from original error), got %q", prob.Category)
}
// Assert cause preservation
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to wrap APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("expected errors.Is to find the wrapped APIError")
}
}
func TestBatchCreateExecute_RollbackDeleteFails(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
// Objective creation succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/cycles/123/objectives",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"objective_id": "100",
},
},
})
// KR creation fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v2/objectives/100/key_results",
Status: 400,
Body: map[string]interface{}{
"code": 1001001,
"msg": "invalid parameters",
},
})
// Rollback delete also fails
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/okr/v2/objectives/100",
Status: 500,
Body: map[string]interface{}{
"code": 9999999,
"msg": "internal error",
},
})
err := runBatchCreateShortcut(t, f, stdout, []string{
"+batch-create",
"--cycle-id", "123",
"--input", `[{"text":"Obj 1","krs":[{"text":"KR 1"}]}]`,
})
if err == nil {
t.Fatal("expected error for KR creation failure")
}
// Error should mention residual resources
if !strings.Contains(err.Error(), "residual") && !strings.Contains(err.Error(), "manual cleanup") {
t.Fatalf("expected error to mention residual resources, got: %v", err)
}
if !strings.Contains(err.Error(), "objective:100") {
t.Fatalf("expected error to list residual objective ID, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseBatchCreateInput_Valid(t *testing.T) {
t.Parallel()
input := `[{"text":"Obj 1","mention":["ou_123"],"krs":[{"text":"KR 1","mention":["ou_456"]}]}]`
objs, err := parseBatchCreateInput(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(objs) != 1 {
t.Fatalf("expected 1 objective, got %d", len(objs))
}
if objs[0].Text != "Obj 1" {
t.Fatalf("expected text 'Obj 1', got %q", objs[0].Text)
}
if len(objs[0].Mention) != 1 || objs[0].Mention[0] != "ou_123" {
t.Fatalf("expected mention ['ou_123'], got %v", objs[0].Mention)
}
if len(objs[0].KRs) != 1 {
t.Fatalf("expected 1 KR, got %d", len(objs[0].KRs))
}
if objs[0].KRs[0].Text != "KR 1" {
t.Fatalf("expected KR text 'KR 1', got %q", objs[0].KRs[0].Text)
}
}
func TestBuildContentBlock(t *testing.T) {
t.Parallel()
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
block := cb.Blocks[0]
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
t.Fatalf("expected paragraph block type")
}
if block.Paragraph == nil {
t.Fatal("expected non-nil paragraph")
}
// Should have 3 elements: 1 text + 2 mentions
if len(block.Paragraph.Elements) != 3 {
t.Fatalf("expected 3 paragraph elements, got %d", len(block.Paragraph.Elements))
}
// First element should be textRun
if block.Paragraph.Elements[0].ParagraphElementType == nil ||
*block.Paragraph.Elements[0].ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected first element to be textRun")
}
if block.Paragraph.Elements[0].TextRun == nil || *block.Paragraph.Elements[0].TextRun.Text != "Test text" {
t.Fatalf("expected text 'Test text', got %v", block.Paragraph.Elements[0].TextRun)
}
// Second and third should be mentions
for i := 1; i <= 2; i++ {
if block.Paragraph.Elements[i].ParagraphElementType == nil ||
*block.Paragraph.Elements[i].ParagraphElementType != ParagraphElementTypeMention {
t.Fatalf("expected element %d to be mention", i)
}
}
}

View File

@@ -0,0 +1,178 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"fmt"
"io"
"math"
"strconv"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// parseIndicatorValue parses and validates the indicator value.
func parseIndicatorValue(valueStr string) (float64, error) {
value, err := strconv.ParseFloat(valueStr, 64)
if err != nil {
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value").WithCause(err)
}
if math.IsNaN(value) || math.IsInf(value, 0) || value < -99999999999 || value > 99999999999 {
return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "--value must be a number between -99999999999 and 99999999999").WithParam("--value")
}
return value, nil
}
// fetchIndicatorID fetches the indicator ID for an objective or key result.
// The indicators.list API returns a single indicator object (not a list),
// which always exists (may be a default empty indicator).
func fetchIndicatorID(ctx context.Context, runtime *common.RuntimeContext, level string, id string) (string, error) {
var path string
var params map[string]interface{}
if level == "objective" {
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
params = map[string]interface{}{"page_size": 100}
} else {
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
params = map[string]interface{}{"page_size": 100}
}
data, err := runtime.CallAPITyped("GET", path, params, nil)
if err != nil {
return "", wrapOkrNetworkErr(err, "failed to fetch indicators")
}
// Parse response to get indicator ID
// Response format: {"indicator": {"id": "...", ...}} (single object, not a list)
indicator, ok := data["indicator"].(map[string]interface{})
if !ok {
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator field not found in response")
}
indicatorID, ok := indicator["id"].(string)
if !ok || indicatorID == "" {
return "", errs.NewInternalError(errs.SubtypeUnknown, "indicator ID not found or empty")
}
return indicatorID, nil
}
// OKRIndicatorUpdate updates the current value of an indicator for an objective or key result.
var OKRIndicatorUpdate = common.Shortcut{
Service: "okr",
Command: "+indicator-update",
Description: "Update the indicator current value for an objective or key result",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to update: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
{Name: "id", Desc: "objective or key result ID (int64), Required."},
{Name: "value", Desc: "new current value for the indicator (number), Required."},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
}
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
id := runtime.Str("id")
if id == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id is required").WithParam("--id")
}
if _, err := strconv.ParseInt(id, 10, 64); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--id must be a valid int64").WithParam("--id")
}
if runtime.Str("value") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--value is required").WithParam("--value")
}
if _, err := parseIndicatorValue(runtime.Str("value")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
id := runtime.Str("id")
value, _ := parseIndicatorValue(runtime.Str("value"))
apis := common.NewDryRunAPI()
var listPath string
if level == "objective" {
listPath = fmt.Sprintf("/open-apis/okr/v2/objectives/%s/indicators", id)
} else {
listPath = fmt.Sprintf("/open-apis/okr/v2/key_results/%s/indicators", id)
}
// First API: fetch indicator list
apis = apis.
GET(listPath).
Params(map[string]interface{}{"page_size": 100}).
Desc(fmt.Sprintf("Fetch indicators for the %s to get indicator ID", level))
// Second API: patch indicator value
patchPath := "/open-apis/okr/v2/indicators/:indicator_id"
patchBody := map[string]interface{}{
"current_value": value,
}
apis = apis.
PATCH(patchPath).
Body(patchBody).
Set("indicator_id", "<indicator_id_from_list>").
Desc("Update indicator current value")
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
id := runtime.Str("id")
value, err := parseIndicatorValue(runtime.Str("value"))
if err != nil {
return err
}
// Step 1: Fetch indicator ID
indicatorID, err := fetchIndicatorID(ctx, runtime, level, id)
if err != nil {
return err
}
// Step 2: Update indicator value
patchPath := fmt.Sprintf("/open-apis/okr/v2/indicators/%s", indicatorID)
patchBody := map[string]interface{}{
"current_value": value,
}
_, err = runtime.CallAPITyped("PATCH", patchPath, nil, patchBody)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update indicator value")
}
// Build response
result := map[string]interface{}{
"indicator_id": indicatorID,
"current_value": value,
"level": level,
"target_id": id,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Indicator [%s]\n", indicatorID)
fmt.Fprintf(w, " Level: %s\n", level)
fmt.Fprintf(w, " Target ID: %s\n", id)
fmt.Fprintf(w, " Current Value: %v\n", value)
})
return nil
},
}

View File

@@ -0,0 +1,391 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
func indicatorUpdateTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-indicator-update",
AppSecret: "secret-okr-indicator-update",
Brand: core.BrandFeishu,
}
}
func runIndicatorUpdateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRIndicatorUpdate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestIndicatorUpdateValidate_MissingLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--id", "123",
"--value", "50",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "invalid",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for invalid level")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_MissingID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--value", "50",
})
if err == nil || !strings.Contains(err.Error(), "id") {
t.Fatalf("expected --id required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "not-a-number",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for invalid id")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--id" {
t.Fatalf("expected param --id, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_MissingValue(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
})
if err == nil || !strings.Contains(err.Error(), "value") {
t.Fatalf("expected --value required error, got: %v", err)
}
}
func TestIndicatorUpdateValidate_InvalidValue(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "not-a-number",
})
if err == nil {
t.Fatal("expected error for invalid value")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--value" {
t.Fatalf("expected param --value, got %q", validationErr.Param)
}
}
func TestIndicatorUpdateValidate_Valid(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "75.5",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// --- Execute tests ---
func TestIndicatorUpdateExecute_Objectives_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
BodyFilter: func(body []byte) bool {
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return false
}
val, ok := data["current_value"].(float64)
return ok && val == 75.5
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "75.5",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIndicatorUpdateExecute_KeyResults_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/key_results/456/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-789",
},
},
},
})
// Mock patch indicator
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-789",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "key-result",
"--id", "456",
"--value", "100",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestIndicatorUpdateExecute_FetchAPIError(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators - API error
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 9999,
"msg": "fetch error",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for fetch API failure")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("errors.Is should find the APIError in the chain")
}
}
func TestIndicatorUpdateExecute_PatchAPIError(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, indicatorUpdateTestConfig(t))
// Mock fetch indicators
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/123/indicators",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"indicator": map[string]interface{}{
"id": "ind-456",
},
},
},
})
// Mock patch indicator - API error
reg.Register(&httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/okr/v2/indicators/ind-456",
Body: map[string]interface{}{
"code": 9999,
"msg": "patch error",
},
})
err := runIndicatorUpdateShortcut(t, f, stdout, []string{
"+indicator-update",
"--level", "objective",
"--id", "123",
"--value", "50",
})
if err == nil {
t.Fatal("expected error for patch API failure")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryAPI {
t.Fatalf("expected CategoryAPI, got %q", prob.Category)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("expected error to be *errs.APIError, got: %T", err)
}
if !errors.Is(err, apiErr) {
t.Fatal("errors.Is should find the APIError in the chain")
}
}
// --- parseIndicatorValue tests ---
func TestParseIndicatorValue_Valid(t *testing.T) {
t.Parallel()
tests := []string{"0", "100", "75.5", "-10", "0.001", "99999999999"}
for _, v := range tests {
result, err := parseIndicatorValue(v)
if err != nil {
t.Fatalf("expected no error for %q, got: %v", v, err)
}
_ = result
}
}
func TestParseIndicatorValue_Invalid(t *testing.T) {
t.Parallel()
tests := []string{"", "abc", "1e100000", "100000000000"}
for _, v := range tests {
_, err := parseIndicatorValue(v)
if err == nil {
t.Fatalf("expected error for %q", v)
}
}
}

View File

@@ -0,0 +1,448 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// reorderItem is the interface for items that have an ID.
type reorderItem interface {
GetID() string
}
// reorderOp represents a single reorder operation.
type reorderOp struct {
ID string `json:"id"`
Position int32 `json:"position"`
}
// parseReorderOps parses and validates the --ops JSON array.
func parseReorderOps(opsStr string) ([]reorderOp, error) {
var ops []reorderOp
if err := json.Unmarshal([]byte(opsStr), &ops); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must be valid JSON array: %s", err).WithParam("--ops").WithCause(err)
}
if len(ops) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops must contain at least one operation").WithParam("--ops")
}
seen := make(map[string]bool)
seenPos := make(map[int32]bool)
for i, op := range ops {
if strings.TrimSpace(op.ID) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].id is required and cannot be empty", i).WithParam("--ops")
}
if op.Position <= 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "ops[%d].position must be a positive integer", i).WithParam("--ops")
}
if seen[op.ID] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --ops", op.ID).WithParam("--ops")
}
if seenPos[op.Position] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate position %d in --ops", op.Position).WithParam("--ops")
}
seen[op.ID] = true
seenPos[op.Position] = true
}
return ops, nil
}
// fetchObjectives fetches all objectives in a cycle.
func fetchObjectives(ctx context.Context, runtime *common.RuntimeContext, cycleID string) ([]Objective, error) {
queryParams := map[string]interface{}{"page_size": "100"}
var objectives []Objective
page := 0
for {
if page > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
page++
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return nil, wrapOkrNetworkErr(err, "failed to fetch objectives")
}
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["page_token"] = pageToken
}
// Sort objectives by position
sort.Slice(objectives, func(i, j int) bool {
pi := int32(0)
if objectives[i].Position != nil {
pi = *objectives[i].Position
}
pj := int32(0)
if objectives[j].Position != nil {
pj = *objectives[j].Position
}
return pi < pj
})
return objectives, nil
}
// fetchKeyResults fetches all key results for an objective.
func fetchKeyResults(ctx context.Context, runtime *common.RuntimeContext, objectiveID string) ([]KeyResult, error) {
queryParams := map[string]interface{}{"page_size": "100"}
var keyResults []KeyResult
page := 0
for {
if page > 0 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
page++
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
data, err := runtime.CallAPITyped("GET", path, queryParams, nil)
if err != nil {
return nil, wrapOkrNetworkErr(err, "failed to fetch key results")
}
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
}
queryParams["page_token"] = pageToken
}
// Sort key results by position
sort.Slice(keyResults, func(i, j int) bool {
pi := int32(0)
if keyResults[i].Position != nil {
pi = *keyResults[i].Position
}
pj := int32(0)
if keyResults[j].Position != nil {
pj = *keyResults[j].Position
}
return pi < pj
})
return keyResults, nil
}
// buildReorderedIDs builds the complete ordered ID list from current items and reorder ops.
// Positions are treated as 1-indexed placement keys stored in a map (safe for large values).
// Items are first placed at user-specified positions, remaining items fill empty slots
// in original order starting from position 1, and final output is sorted by position.
func buildReorderedIDs[T reorderItem](items []T, ops []reorderOp, total int) ([]string, error) {
// Create a map of ID to current position
idToPos := make(map[string]int)
for i, item := range items {
idToPos[item.GetID()] = i
}
// Validate all ops IDs exist
for _, op := range ops {
if _, ok := idToPos[op.ID]; !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "id %q not found in current list", op.ID).WithParam("--ops")
}
}
// Use map to store position -> ID (1-indexed, safe for large position values)
posToID := make(map[int]string)
used := make(map[string]bool)
for _, op := range ops {
posToID[int(op.Position)] = op.ID
used[op.ID] = true
}
// Collect unused items in original order
var unused []string
for _, item := range items {
id := item.GetID()
if !used[id] {
unused = append(unused, id)
}
}
// Fill empty slots starting from position 1, in original order
unusedIdx := 0
for pos := 1; unusedIdx < len(unused); pos++ {
if _, occupied := posToID[pos]; !occupied {
posToID[pos] = unused[unusedIdx]
unusedIdx++
}
}
// Collect all positions, sort them, and build result in position order
positions := make([]int, 0, len(posToID))
for pos := range posToID {
positions = append(positions, pos)
}
sort.Ints(positions)
result := make([]string, 0, len(positions))
for _, pos := range positions {
result = append(result, posToID[pos])
}
return result, nil
}
// GetID implements the interface for Objective.
func (o Objective) GetID() string { return o.ID }
// GetID implements the interface for KeyResult.
func (k KeyResult) GetID() string { return k.ID }
// OKRReorder adjusts the position of objectives or key results.
var OKRReorder = common.Shortcut{
Service: "okr",
Command: "+reorder",
Description: "Adjust the position (order) of OKR objectives or key results",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to reorder: objective | key-result, Required.", Enum: []string{"objective", "key-result"}},
{Name: "cycle-id", Desc: "OKR cycle ID (int64), Required."},
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
{Name: "ops", Desc: "JSON array of reorder operations: [{\"id\":\"...\",\"position\":1}], Required.", Input: []string{common.File, common.Stdin}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if strings.TrimSpace(level) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level is required").WithParam("--level")
}
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
cycleID := runtime.Str("cycle-id")
if strings.TrimSpace(cycleID) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required").WithParam("--cycle-id")
}
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
if level == "key-result" {
objID := runtime.Str("objective-id")
if objID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
}
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
}
}
opsStr := runtime.Str("ops")
if strings.TrimSpace(opsStr) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--ops is required").WithParam("--ops")
}
if err := common.RejectDangerousCharsTyped("--ops", opsStr); err != nil {
return err
}
if _, err := parseReorderOps(opsStr); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, _ := parseReorderOps(runtime.Str("ops"))
apis := common.NewDryRunAPI()
if level == "objective" {
// First fetch objectives
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all objectives in the cycle to determine current order")
// Then reorder
reorderParams := map[string]interface{}{
"cycle_id": cycleID,
}
// Build sample body with placeholder IDs
objectiveIDs := make([]string, 0, len(ops))
for _, op := range ops {
objectiveIDs = append(objectiveIDs, op.ID)
}
reorderBody := map[string]interface{}{
"objective_ids": objectiveIDs,
}
reorderPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
apis = apis.
PUT(reorderPath).
Params(reorderParams).
Body(reorderBody).
Desc("Update objective positions (full list sent, not just changes)")
} else {
// key-result level
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all key results for the objective to determine current order")
reorderParams := map[string]interface{}{
"objective_id": objectiveID,
}
// Build sample body with placeholder IDs
keyResultIDs := make([]string, 0, len(ops))
for _, op := range ops {
keyResultIDs = append(keyResultIDs, op.ID)
}
reorderBody := map[string]interface{}{
"key_result_ids": keyResultIDs,
}
reorderPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
apis = apis.
PUT(reorderPath).
Params(reorderParams).
Body(reorderBody).
Desc("Update key result positions (full list sent, not just changes)")
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, err := parseReorderOps(runtime.Str("ops"))
if err != nil {
return err
}
var reorderedIDs []string
var total int
if level == "objective" {
objectives, err := fetchObjectives(ctx, runtime, cycleID)
if err != nil {
return err
}
total = len(objectives)
reorderedIDs, err = buildReorderedIDs(objectives, ops, total)
if err != nil {
return err
}
// Submit reorder
params := map[string]interface{}{
"cycle_id": cycleID,
}
body := map[string]interface{}{
"objective_ids": reorderedIDs,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_position", cycleID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update objective positions")
}
} else {
// key-result level
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
if err != nil {
return err
}
total = len(keyResults)
reorderedIDs, err = buildReorderedIDs(keyResults, ops, total)
if err != nil {
return err
}
// Submit reorder
params := map[string]interface{}{
"objective_id": objectiveID,
}
body := map[string]interface{}{
"key_result_ids": reorderedIDs,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_position", objectiveID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update key result positions")
}
}
// Build response
result := map[string]interface{}{
"level": level,
"cycle_id": cycleID,
"total": total,
"ordered": reorderedIDs,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully reordered %d %s(s)\n", total, level)
fmt.Fprintln(w, "New order:")
for i, id := range reorderedIDs {
fmt.Fprintf(w, " Position %d: %s\n", i+1, id)
}
})
return nil
},
}

View File

@@ -0,0 +1,712 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
// testReorderItem implements reorderItem for testing.
type testReorderItem struct {
id string
}
func (t testReorderItem) GetID() string { return t.id }
func reorderTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-okr-reorder",
AppSecret: "secret-okr-reorder",
Brand: core.BrandFeishu,
}
}
func runReorderShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRReorder.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestReorderValidate_MissingLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestReorderValidate_InvalidLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "invalid",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
if err == nil {
t.Fatal("expected error for invalid --level")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestReorderValidate_MissingCycleID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--ops", `[{"id":"1","position":1}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestReorderValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1}]`,
})
if err == nil {
t.Fatal("expected error for missing --objective-id when --level=key-result")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--objective-id" {
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
}
}
func TestReorderValidate_MissingOps(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "ops") {
t.Fatalf("expected --ops required error, got: %v", err)
}
}
func TestReorderValidate_InvalidOpsJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --ops JSON")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
func TestReorderValidate_EmptyOpsArray(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", "[]",
})
if err == nil {
t.Fatal("expected error for empty --ops array")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
func TestReorderValidate_DuplicateID(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1},{"id":"1","position":2}]`,
})
if err == nil {
t.Fatal("expected error for duplicate id in --ops")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate id") {
t.Fatalf("expected error to mention duplicate id, got: %v", err)
}
}
func TestReorderValidate_DuplicatePosition(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":1},{"id":"2","position":1}]`,
})
if err == nil {
t.Fatal("expected error for duplicate position in --ops")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate position") {
t.Fatalf("expected error to mention duplicate position, got: %v", err)
}
}
func TestReorderValidate_NegativePosition(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":0}]`,
})
if err == nil {
t.Fatal("expected error for position <= 0")
}
_, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
validationErr, ok := err.(*errs.ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got: %T", err)
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
}
// --- DryRun tests ---
func TestReorderDryRun_Objectives(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
}
if !strings.Contains(output, "GET") {
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_position") {
t.Fatalf("dry-run output should contain position update API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
}
if !strings.Contains(output, "objective_ids") {
t.Fatalf("dry-run output should contain objective_ids in body, got: %s", output)
}
}
func TestReorderDryRun_KeyResults(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, reorderTestConfig(t))
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--ops", `[{"id":"1","position":2},{"id":"2","position":1}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_position") {
t.Fatalf("dry-run output should contain key_results position update API path, got: %s", output)
}
if !strings.Contains(output, "key_result_ids") {
t.Fatalf("dry-run output should contain key_result_ids in body, got: %s", output)
}
}
// --- Execute tests ---
func TestReorderExecute_Objectives_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives
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{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "3", "position": 3, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock reorder
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "3", "position": 1},
map[string]interface{}{"id": "1", "position": 2},
map[string]interface{}{"id": "2", "position": 3},
},
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"3","position":1}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "objective" {
t.Fatalf("expected level=objective, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
ordered, _ := data["ordered"].([]interface{})
if len(ordered) != 3 {
t.Fatalf("expected 3 items in ordered list, got %d", len(ordered))
}
// First should be 3, then 1, then 2
if ordered[0] != "3" || ordered[1] != "1" || ordered[2] != "2" {
t.Fatalf("expected ordered [3,1,2], got %v", ordered)
}
}
func TestReorderExecute_KeyResults_Success(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch key results
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/456/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr1", "position": 1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr2", "position": 2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock reorder
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/objectives/456/key_results_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr2", "position": 1},
map[string]interface{}{"id": "kr1", "position": 2},
},
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--ops", `[{"id":"kr2","position":1}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "key-result" {
t.Fatalf("expected level=key-result, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
ordered, _ := data["ordered"].([]interface{})
if len(ordered) != 2 {
t.Fatalf("expected 2 items in ordered list, got %d", len(ordered))
}
if ordered[0] != "kr2" || ordered[1] != "kr1" {
t.Fatalf("expected ordered [kr2,kr1], got %v", ordered)
}
}
func TestReorderExecute_PositionOutOfRange(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives (only 2 items)
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{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_position",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
BodyFilter: func(body []byte) bool {
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return false
}
ids, ok := data["objective_ids"].([]interface{})
if !ok || len(ids) != 2 {
return false
}
// position 5 should be clamped to position 2 (last), so order is [2, 1]
return ids[0] == "2" && ids[1] == "1"
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"1","position":5}]`, // position 5 exceeds total of 2, should clamp to last
})
if err != nil {
t.Fatalf("unexpected error for out-of-range position (should clamp): %v", err)
}
}
func TestReorderExecute_IDNotFound(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, reorderTestConfig(t))
// Mock fetch objectives
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{}{
map[string]interface{}{"id": "1", "position": 1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "position": 2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
err := runReorderShortcut(t, f, stdout, []string{
"+reorder",
"--level", "objective",
"--cycle-id", "123",
"--ops", `[{"id":"999","position":1}]`, // ID 999 doesn't exist
})
if err == nil {
t.Fatal("expected error for non-existent ID")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--ops" {
t.Fatalf("expected param --ops, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected error to mention not found, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseReorderOps_Valid(t *testing.T) {
t.Parallel()
ops, err := parseReorderOps(`[{"id":"1","position":2},{"id":"2","position":1}]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ops) != 2 {
t.Fatalf("expected 2 ops, got %d", len(ops))
}
if ops[0].ID != "1" || ops[0].Position != 2 {
t.Fatalf("expected op[0] = {1,2}, got %+v", ops[0])
}
if ops[1].ID != "2" || ops[1].Position != 1 {
t.Fatalf("expected op[1] = {2,1}, got %+v", ops[1])
}
}
func TestBuildReorderedIDs(t *testing.T) {
t.Parallel()
items := []Objective{
{ID: "1"},
{ID: "2"},
{ID: "3"},
{ID: "4"},
}
ops := []reorderOp{
{ID: "4", Position: 1},
{ID: "2", Position: 3},
}
result, err := buildReorderedIDs(items, ops, 4)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 4 at pos1, 1 at pos2 (unchanged), 2 at pos3, 3 at pos4
expected := []string{"4", "1", "2", "3"}
if len(result) != len(expected) {
t.Fatalf("expected %d items, got %d", len(expected), len(result))
}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_SingleClampToEnd(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"},
}
ops := []reorderOp{
{ID: "1", Position: 99}, // position 99 exceeds total of 4, should clamp to last
}
result, err := buildReorderedIDs(items, ops, 4)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 2 at pos1, 3 at pos2, 4 at pos3, 1 at pos4 (clamped)
expected := []string{"2", "3", "4", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_MultipleClampToEnd(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
}
ops := []reorderOp{
{ID: "1", Position: 10}, // position 10 exceeds total of 5
{ID: "2", Position: 20}, // position 20 exceeds total of 5
}
result, err := buildReorderedIDs(items, ops, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 3 at pos1, 4 at pos2, 5 at pos3, 1 at pos4 (clamped, pos10 < pos20), 2 at pos5 (clamped)
expected := []string{"3", "4", "5", "1", "2"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_MixedClamp(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"}, {id: "4"}, {id: "5"},
}
ops := []reorderOp{
{ID: "5", Position: 1}, // normal position
{ID: "1", Position: 99}, // clamped to end
{ID: "2", Position: 50}, // clamped to end, but position 50 < 99, so comes before 1
}
result, err := buildReorderedIDs(items, ops, 5)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 5 at pos1, 3 at pos2, 4 at pos3, 2 at pos4 (clamped pos50), 1 at pos5 (clamped pos99)
expected := []string{"5", "3", "4", "2", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}
func TestBuildReorderedIDs_LargePositionSafe(t *testing.T) {
t.Parallel()
items := []testReorderItem{
{id: "1"}, {id: "2"}, {id: "3"},
}
// Very large position should not cause memory issues with map-based implementation
ops := []reorderOp{
{ID: "1", Position: 100000000}, // 10^8, would be dangerous with slice
}
result, err := buildReorderedIDs(items, ops, 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Expected: 2 at pos1, 3 at pos2, 1 at pos3 (clamped to end)
expected := []string{"2", "3", "1"}
for i, id := range expected {
if result[i] != id {
t.Fatalf("expected result[%d] = %q, got %q", i, id, result[i])
}
}
}

490
shortcuts/okr/okr_weight.go Normal file
View File

@@ -0,0 +1,490 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"sort"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// weightItem is the interface for items that have ID and weight.
type weightItem interface {
GetID() string
GetWeight() float64
}
// weightOp represents a single weight assignment.
type weightOp struct {
ID string `json:"id"`
Weight float64 `json:"weight"`
}
// parseWeightOps parses and validates the --weights JSON array.
func parseWeightOps(weightsStr string) ([]weightOp, error) {
var ops []weightOp
if err := json.Unmarshal([]byte(weightsStr), &ops); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must be valid JSON array: %s", err).WithParam("--weights").WithCause(err)
}
if len(ops) == 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--weights must contain at least one weight assignment").WithParam("--weights")
}
seen := make(map[string]bool)
var sum float64
for i, op := range ops {
if strings.TrimSpace(op.ID) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].id is required and cannot be empty", i).WithParam("--weights")
}
if op.Weight < 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be non-negative", i).WithParam("--weights")
}
if op.Weight > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must be <= 1", i).WithParam("--weights")
}
// Check for at most 3 decimal places
if math.Round(op.Weight*1000)/1000 != op.Weight {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "weights[%d].weight must have at most 3 decimal places", i).WithParam("--weights")
}
if seen[op.ID] {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate id %q in --weights", op.ID).WithParam("--weights")
}
seen[op.ID] = true
sum += op.Weight
}
// Sum must be <= 1
if sum > 1+1e-9 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "sum of weights must be <= 1, got %.6f", sum).WithParam("--weights")
}
return ops, nil
}
// formatWeight formats a fixed-point weight value as a json.Number with exactly 3 decimal places.
// This ensures precise JSON serialization and avoids float64 precision issues.
func formatWeight(fp int64) json.Number {
return json.Number(fmt.Sprintf("%d.%03d", fp/1000, fp%1000))
}
// normalizeWeights normalizes weights using fixed-point arithmetic (×1000).
// - Specified weights are used as-is (already validated to 3 decimal places).
// - Remaining weight (1 - sum_specified) is distributed to unspecified items
// proportionally based on their original weights.
// - Fixed-point arithmetic ensures exact sum = 1, with residual added to the last item.
// - Weights are returned as json.Number to avoid float64 precision issues in JSON serialization.
func normalizeWeights[T weightItem](
items []T,
ops []weightOp,
) ([]map[string]interface{}, error) {
const scale = 1000 // fixed-point scale for 3 decimal places
// Build map of specified weights (as fixed-point integers)
specified := make(map[string]int64)
var specifiedSum int64
for _, op := range ops {
fp := int64(math.Round(op.Weight * scale))
specified[op.ID] = fp
specifiedSum += fp
}
// Calculate remaining weight to distribute (as fixed-point)
remaining := scale - specifiedSum
if remaining < 0 {
return nil, errs.NewInternalError(errs.SubtypeUnknown, "weight calculation error: remaining weight is negative")
}
// Collect unspecified items and their original weights
type itemWithWeight struct {
item T
fp int64 // original weight as fixed-point
}
var unspecified []itemWithWeight
var originalUnspecifiedSum int64
for _, item := range items {
id := item.GetID()
if _, ok := specified[id]; ok {
continue
}
origWeight := item.GetWeight()
if origWeight < 0 {
origWeight = 0
}
fp := int64(math.Round(origWeight * scale))
unspecified = append(unspecified, itemWithWeight{item: item, fp: fp})
originalUnspecifiedSum += fp
}
// Distribute remaining weight proportionally
result := make([]map[string]interface{}, 0, len(items))
var resultSum int64
// First add specified items in original order
for _, item := range items {
id := item.GetID()
if fp, ok := specified[id]; ok {
result = append(result, map[string]interface{}{
"id": id,
"weight": formatWeight(fp),
})
resultSum += fp
}
}
// Then distribute to unspecified items
if len(unspecified) > 0 && remaining > 0 {
if originalUnspecifiedSum == 0 {
// All original weights are zero, distribute evenly
perItem := remaining / int64(len(unspecified))
residual := remaining - perItem*int64(len(unspecified))
for i, uw := range unspecified {
fp := perItem
// Add residual to the last unspecified item
if i == len(unspecified)-1 {
fp += residual
}
result = append(result, map[string]interface{}{
"id": uw.item.GetID(),
"weight": formatWeight(fp),
})
resultSum += fp
}
} else {
// Distribute proportionally based on original weights
var distributed int64
for i, uw := range unspecified {
var fp int64
if i == len(unspecified)-1 {
// Last item gets the remainder to ensure exact sum
fp = remaining - distributed
} else {
// Proportional distribution
fp = int64(float64(remaining) * float64(uw.fp) / float64(originalUnspecifiedSum))
distributed += fp
}
result = append(result, map[string]interface{}{
"id": uw.item.GetID(),
"weight": formatWeight(fp),
})
resultSum += fp
}
}
} else if remaining > 0 {
// All items were specified, add residual to the last item
if len(result) > 0 {
lastIdx := len(result) - 1
// Parse current weight as fixed-point and add residual
var lastFP int64
if lastWeight, ok := result[lastIdx]["weight"].(json.Number); ok {
if f, err := lastWeight.Float64(); err == nil {
lastFP = int64(math.Round(f * scale))
}
}
result[lastIdx]["weight"] = formatWeight(lastFP + remaining)
resultSum += remaining
}
}
// Verify sum is exactly 1.0
if resultSum != scale {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"weight normalization error: sum is %.6f, expected 1.0", float64(resultSum)/scale)
}
return result, nil
}
// GetWeight implements the interface for Objective.
func (o Objective) GetWeight() float64 {
if o.Weight == nil {
return 0
}
return *o.Weight
}
// GetWeight implements the interface for KeyResult.
func (k KeyResult) GetWeight() float64 {
if k.Weight == nil {
return 0
}
return *k.Weight
}
// OKRWeight adjusts the weight of objectives or key results.
var OKRWeight = common.Shortcut{
Service: "okr",
Command: "+weight",
Description: "Adjust the weight of OKR objectives or key results",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "level to adjust: objective | key-result", Enum: []string{"objective", "key-result"}, Required: true},
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
{Name: "objective-id", Desc: "objective ID (required when --level=key-result)"},
{Name: "weights", Desc: "JSON array of weight assignments: [{\"id\":\"...\",\"weight\":0.5}]", Input: []string{common.File, common.Stdin}, Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
cycleID := runtime.Str("cycle-id")
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
}
if level == "key-result" {
objID := runtime.Str("objective-id")
if objID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
}
if id, err := strconv.ParseInt(objID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
}
}
weightsStr := runtime.Str("weights")
if err := common.RejectDangerousCharsTyped("--weights", weightsStr); err != nil {
return err
}
if _, err := parseWeightOps(weightsStr); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, _ := parseWeightOps(runtime.Str("weights"))
apis := common.NewDryRunAPI()
if level == "objective" {
// First fetch objectives
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", cycleID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all objectives in the cycle to get current weights for normalization")
// Then update weights
weightParams := map[string]interface{}{
"cycle_id": cycleID,
}
// Build sample body
objectiveWeights := make([]map[string]interface{}, 0, len(ops))
for _, op := range ops {
objectiveWeights = append(objectiveWeights, map[string]interface{}{
"objective_id": op.ID,
"weight": op.Weight,
})
}
weightBody := map[string]interface{}{
"objective_weights": objectiveWeights,
}
weightPath := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
apis = apis.
PUT(weightPath).
Params(weightParams).
Body(weightBody).
Desc("Update objective weights (full list sent after normalization)")
} else {
// key-result level
listParams := map[string]interface{}{
"page_size": 100,
}
listPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", objectiveID)
apis = apis.
GET(listPath).
Params(listParams).
Desc("Fetch all key results for the objective to get current weights for normalization")
weightParams := map[string]interface{}{
"objective_id": objectiveID,
}
// Build sample body
keyResultWeights := make([]map[string]interface{}, 0, len(ops))
for _, op := range ops {
keyResultWeights = append(keyResultWeights, map[string]interface{}{
"key_result_id": op.ID,
"weight": op.Weight,
})
}
weightBody := map[string]interface{}{
"key_result_weights": keyResultWeights,
}
weightPath := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
apis = apis.
PUT(weightPath).
Params(weightParams).
Body(weightBody).
Desc("Update key result weights (full list sent after normalization)")
}
return apis
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
cycleID := runtime.Str("cycle-id")
objectiveID := runtime.Str("objective-id")
ops, err := parseWeightOps(runtime.Str("weights"))
if err != nil {
return err
}
var normalizedWeights []map[string]interface{}
var total int
if level == "objective" {
objectives, err := fetchObjectives(ctx, runtime, cycleID)
if err != nil {
return err
}
total = len(objectives)
// Validate all specified IDs exist
objIDs := make(map[string]bool)
for _, obj := range objectives {
objIDs[obj.ID] = true
}
for _, op := range ops {
if !objIDs[op.ID] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "objective id %q not found in cycle", op.ID).WithParam("--weights")
}
}
normalizedWeights, err = normalizeWeights(objectives, ops)
if err != nil {
return err
}
// Build position map for sorting
posMap := make(map[string]int32)
for _, obj := range objectives {
if obj.Position != nil {
posMap[obj.ID] = *obj.Position
}
}
// Submit weight update
params := map[string]interface{}{
"cycle_id": cycleID,
}
objectiveWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
for _, w := range normalizedWeights {
objectiveWeights = append(objectiveWeights, map[string]interface{}{
"objective_id": w["id"],
"weight": w["weight"],
})
}
// Sort by position to match API requirements
sort.Slice(objectiveWeights, func(i, j int) bool {
idI := objectiveWeights[i]["objective_id"].(string)
idJ := objectiveWeights[j]["objective_id"].(string)
return posMap[idI] < posMap[idJ]
})
body := map[string]interface{}{
"objective_weights": objectiveWeights,
}
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives_weight", cycleID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update objective weights")
}
} else {
// key-result level
keyResults, err := fetchKeyResults(ctx, runtime, objectiveID)
if err != nil {
return err
}
total = len(keyResults)
// Validate all specified IDs exist
krIDs := make(map[string]bool)
for _, kr := range keyResults {
krIDs[kr.ID] = true
}
for _, op := range ops {
if !krIDs[op.ID] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "key_result id %q not found in objective", op.ID).WithParam("--weights")
}
}
normalizedWeights, err = normalizeWeights(keyResults, ops)
if err != nil {
return err
}
// Build position map for sorting
posMap := make(map[string]int32)
for _, kr := range keyResults {
if kr.Position != nil {
posMap[kr.ID] = *kr.Position
}
}
// Submit weight update
params := map[string]interface{}{
"objective_id": objectiveID,
}
keyResultWeights := make([]map[string]interface{}, 0, len(normalizedWeights))
for _, w := range normalizedWeights {
keyResultWeights = append(keyResultWeights, map[string]interface{}{
"key_result_id": w["id"],
"weight": w["weight"],
})
}
// Sort by position to match API requirements
sort.Slice(keyResultWeights, func(i, j int) bool {
idI := keyResultWeights[i]["key_result_id"].(string)
idJ := keyResultWeights[j]["key_result_id"].(string)
return posMap[idI] < posMap[idJ]
})
body := map[string]interface{}{
"key_result_weights": keyResultWeights,
}
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results_weight", objectiveID)
_, err = runtime.CallAPITyped("PUT", path, params, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to update key result weights")
}
}
// Build response
result := map[string]interface{}{
"level": level,
"cycle_id": cycleID,
"total": total,
"weights": normalizedWeights,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Successfully updated weights for %d %s(s)\n", total, level)
fmt.Fprintln(w, "Weights:")
for _, weightEntry := range normalizedWeights {
fmt.Fprintf(w, " %s: %v\n", weightEntry["id"], weightEntry["weight"])
}
})
return nil
},
}

View File

@@ -0,0 +1,747 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"bytes"
"encoding/json"
"errors"
"math"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
// getWeightFloat extracts a float64 weight from either float64 or json.Number.
func getWeightFloat(v interface{}) float64 {
switch val := v.(type) {
case float64:
return val
case json.Number:
f, _ := val.Float64()
return f
default:
return 0
}
}
func weightTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
return &core.CliConfig{
AppID: "test-okr-weight",
AppSecret: "secret-okr-weight",
Brand: core.BrandFeishu,
}
}
func runWeightShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "okr"}
OKRWeight.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// --- Validate tests ---
func TestWeightValidate_MissingLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "level") {
t.Fatalf("expected --level required error, got: %v", err)
}
}
func TestWeightValidate_InvalidLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "invalid",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for invalid --level")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--level" {
t.Fatalf("expected param --level, got %q", validationErr.Param)
}
}
func TestWeightValidate_MissingCycleID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--weights", `[{"id":"1","weight":0.5}]`,
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "cycle-id") {
t.Fatalf("expected --cycle-id required error, got: %v", err)
}
}
func TestWeightValidate_MissingObjectiveIDForKRLevel(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for missing --objective-id when --level=key-result")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--objective-id" {
t.Fatalf("expected param --objective-id, got %q", validationErr.Param)
}
}
func TestWeightValidate_MissingWeights(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
})
// cobra Required:true reports flag name without "--" prefix
if err == nil || !strings.Contains(err.Error(), "weights") {
t.Fatalf("expected --weights required error, got: %v", err)
}
}
func TestWeightValidate_InvalidWeightsJSON(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", "not-json",
})
if err == nil {
t.Fatal("expected error for invalid --weights JSON")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_EmptyWeightsArray(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", "[]",
})
if err == nil {
t.Fatal("expected error for empty --weights array")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_NegativeWeight(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":-0.1}]`,
})
if err == nil {
t.Fatal("expected error for negative weight")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "non-negative") {
t.Fatalf("expected error to mention non-negative, got: %v", err)
}
}
func TestWeightValidate_WeightGreaterThanOne(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":1.5}]`,
})
if err == nil {
t.Fatal("expected error for weight > 1")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
}
func TestWeightValidate_TooManyDecimalPlaces(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.1234}]`,
})
if err == nil {
t.Fatal("expected error for weight with more than 3 decimal places")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "3 decimal places") {
t.Fatalf("expected error to mention 3 decimal places, got: %v", err)
}
}
func TestWeightValidate_SumGreaterThanOne(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.6},{"id":"2","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for sum of weights > 1")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "sum of weights") {
t.Fatalf("expected error to mention sum of weights, got: %v", err)
}
}
func TestWeightValidate_DuplicateID(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.3},{"id":"1","weight":0.4}]`,
})
if err == nil {
t.Fatal("expected error for duplicate id in --weights")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "duplicate id") {
t.Fatalf("expected error to mention duplicate id, got: %v", err)
}
}
// --- DryRun tests ---
func TestWeightDryRun_Objectives(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.5},{"id":"2","weight":0.5}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives") {
t.Fatalf("dry-run output should contain objectives list API path, got: %s", output)
}
if !strings.Contains(output, "GET") {
t.Fatalf("dry-run output should contain GET method for list, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/cycles/123/objectives_weight") {
t.Fatalf("dry-run output should contain weight update API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method for update, got: %s", output)
}
if !strings.Contains(output, "objective_weights") {
t.Fatalf("dry-run output should contain objective_weights in body, got: %s", output)
}
}
func TestWeightDryRun_KeyResults(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, weightTestConfig(t))
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--weights", `[{"id":"kr1","weight":0.5},{"id":"kr2","weight":0.5}]`,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results") {
t.Fatalf("dry-run output should contain key_results list API path, got: %s", output)
}
if !strings.Contains(output, "/open-apis/okr/v2/objectives/456/key_results_weight") {
t.Fatalf("dry-run output should contain key_results weight update API path, got: %s", output)
}
if !strings.Contains(output, "key_result_weights") {
t.Fatalf("dry-run output should contain key_result_weights in body, got: %s", output)
}
}
// --- Execute tests ---
func TestWeightExecute_Objectives_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch objectives
w1 := 0.5
w2 := 0.5
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{}{
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "2", "weight": &w2, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock weight update
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/cycles/123/objectives_weight",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "1", "weight": 0.7},
map[string]interface{}{"id": "2", "weight": 0.3},
},
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"1","weight":0.7}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "objective" {
t.Fatalf("expected level=objective, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
weights, _ := data["weights"].([]interface{})
if len(weights) != 2 {
t.Fatalf("expected 2 items in weights list, got %d", len(weights))
}
// Verify sum is exactly 1.0
var sum float64
for _, w := range weights {
wm, _ := w.(map[string]interface{})
weightVal := getWeightFloat(wm["weight"])
sum += weightVal
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
}
}
func TestWeightExecute_KeyResults_Success(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch key results
w1 := 0.3
w2 := 0.3
w3 := 0.4
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/okr/v2/objectives/456/key_results",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{"id": "kr1", "weight": &w1, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr2", "weight": &w2, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
map[string]interface{}{"id": "kr3", "weight": &w3, "objective_id": "456", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
// Mock weight update
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v2/objectives/456/key_results_weight",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "key-result",
"--cycle-id", "123",
"--objective-id", "456",
"--weights", `[{"id":"kr1","weight":0.5}]`,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify output
data := decodeEnvelope(t, stdout)
if data["level"] != "key-result" {
t.Fatalf("expected level=key-result, got %v", data["level"])
}
if data["cycle_id"] != "123" {
t.Fatalf("expected cycle_id=123, got %v", data["cycle_id"])
}
weights, _ := data["weights"].([]interface{})
// Verify sum is exactly 1.0
var sum float64
for _, w := range weights {
wm, _ := w.(map[string]interface{})
weightVal := getWeightFloat(wm["weight"])
sum += weightVal
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum of weights = 1.0, got %.10f", sum)
}
}
func TestWeightExecute_IDNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, weightTestConfig(t))
// Mock fetch objectives
w1 := 0.5
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{}{
map[string]interface{}{"id": "1", "weight": &w1, "cycle_id": "123", "owner": map[string]interface{}{"owner_type": "user"}},
},
"has_more": false,
},
},
})
err := runWeightShortcut(t, f, stdout, []string{
"+weight",
"--level", "objective",
"--cycle-id", "123",
"--weights", `[{"id":"999","weight":0.5}]`,
})
if err == nil {
t.Fatal("expected error for non-existent ID")
}
prob, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if prob.Category != errs.CategoryValidation {
t.Fatalf("expected CategoryValidation, got %q", prob.Category)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected error to be *errs.ValidationError, got: %T", err)
}
if !errors.Is(err, validationErr) {
t.Fatal("errors.Is should find the ValidationError in the chain")
}
if validationErr.Param != "--weights" {
t.Fatalf("expected param --weights, got %q", validationErr.Param)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected error to mention not found, got: %v", err)
}
}
// --- Unit tests for helper functions ---
func TestParseWeightOps_Valid(t *testing.T) {
ops, err := parseWeightOps(`[{"id":"1","weight":0.3},{"id":"2","weight":0.7}]`)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(ops) != 2 {
t.Fatalf("expected 2 ops, got %d", len(ops))
}
if ops[0].ID != "1" || math.Abs(ops[0].Weight-0.3) > 1e-9 {
t.Fatalf("expected op[0] = {1,0.3}, got %+v", ops[0])
}
if ops[1].ID != "2" || math.Abs(ops[1].Weight-0.7) > 1e-9 {
t.Fatalf("expected op[1] = {2,0.7}, got %+v", ops[1])
}
}
func TestNormalizeWeights_AllSpecified(t *testing.T) {
w1 := 0.0
w2 := 0.0
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
}
ops := []weightOp{
{ID: "1", Weight: 0.3},
{ID: "2", Weight: 0.7},
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
}
func TestNormalizeWeights_PartialSpecified_Proportional(t *testing.T) {
w1 := 0.5
w2 := 0.3
w3 := 0.2
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
{ID: "3", Weight: &w3},
}
ops := []weightOp{
{ID: "1", Weight: 0.4}, // Specify 0.4 for item 1
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 3 {
t.Fatalf("expected 3 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
// Item 1 should have weight 0.4
var item1Weight float64
for _, r := range result {
if r["id"] == "1" {
item1Weight = getWeightFloat(r["weight"])
break
}
}
if math.Abs(item1Weight-0.4) > 1e-9 {
t.Fatalf("expected item 1 weight = 0.4, got %.10f", item1Weight)
}
}
func TestNormalizeWeights_ZeroOriginalWeights(t *testing.T) {
w1 := 0.0
w2 := 0.0
items := []Objective{
{ID: "1", Weight: &w1},
{ID: "2", Weight: &w2},
}
ops := []weightOp{
{ID: "1", Weight: 0.5},
}
result, err := normalizeWeights(items, ops)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(result) != 2 {
t.Fatalf("expected 2 results, got %d", len(result))
}
// Sum should be exactly 1.0
var sum float64
for _, r := range result {
sum += getWeightFloat(r["weight"])
}
if math.Abs(sum-1.0) > 1e-9 {
t.Fatalf("expected sum = 1.0, got %.10f", sum)
}
// When original weights are zero, remaining should be distributed evenly
var item2Weight float64
for _, r := range result {
if r["id"] == "2" {
item2Weight = getWeightFloat(r["weight"])
break
}
}
if math.Abs(item2Weight-0.5) > 1e-9 {
t.Fatalf("expected item 2 weight = 0.5 (even distribution), got %.10f", item2Weight)
}
}

View File

@@ -18,5 +18,9 @@ func Shortcuts() []common.Shortcut {
OKRUpdateProgressRecord,
OKRDeleteProgressRecord,
OKRUploadImage,
OKRBatchCreate,
OKRReorder,
OKRWeight,
OKRIndicatorUpdate,
}
}

View File

@@ -53,7 +53,7 @@ lark-cli im +chat-messages-list --chat-id oc_xxx --format json
## Resource Rendering
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `[Image: img_xxx]`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
Messages are rendered into human-readable text for inspection. Image messages are shown as placeholders such as `![Image](img_xxx)`; files, audio, and videos are rendered with resource keys in the content (e.g. `<audio key="file_xxx" duration="Xs"/>`). By default resource binaries are **not** downloaded.
Two ways to get the binaries:
- **In one pass:** add `--download-resources` to this command — every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) is downloaded into `./lark-im-resources/` and a `resources` block (`{message_id, key, type, local_path, size_bytes}`) is attached to each message. See [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in).
@@ -61,7 +61,7 @@ Two ways to get the binaries:
| Resource Type | Marker in Content | Behavior |
|---------|-------------|------|
| Image | `[Image: img_xxx]` | `--download-resources`, or manually `im +messages-resources-download --type image` |
| Image | `![Image](img_xxx)` | `--download-resources`, or manually `im +messages-resources-download --type image` |
| File | `<file key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Audio | `<audio key="file_xxx" duration="Xs"/>` | `--download-resources`, or manually `im +messages-resources-download --type file` |
| Video | `<video key="file_xxx" .../>` | `--download-resources`, or manually `im +messages-resources-download --type file` |

View File

@@ -32,7 +32,7 @@ When enabled:
- Output paths are confined to `./lark-im-resources/` by the same guards as [`+messages-resources-download`](lark-im-messages-resources-download.md) (abnormal `file_key` with path separators / `..` / absolute paths is rejected).
- **Scope**: the download uses `GET /open-apis/im/v1/messages/:message_id/resources/:file_key`, which requires `im:message:readonly` — already declared in each listing command's `Scopes`, so `--download-resources` needs **no extra scope** beyond what's required to read the messages (user identity also needs `im:message.group_msg:get_as_user` / `im:message.p2p_msg:get_as_user`; bot identity needs `im:message.group_msg` / `im:message.p2p_msg:readonly`, all already declared). Works under both user and bot identity. If a bot was registered before `im:message:readonly` was granted, a single resource will fail-silently (`error: true` + stderr warning) rather than aborting the pull.
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. `[Image: img_xxx]`, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
Use `--download-resources` when you want the binaries on disk in one pass; otherwise the message content keeps the inline resource markers (e.g. `![Image](img_xxx)`, `<file .../>`, `<audio key="..." duration="Xs"/>`) and you can fetch individual resources later with [`+messages-resources-download`](lark-im-messages-resources-download.md).
## Scope requirement

View File

@@ -90,7 +90,7 @@ lark-cli im +messages-mget --message-ids "om_aaa,om_bbb"
1. **Use JSON for full content:** table output truncates content. Use `--format json` when the full body matters.
2. **Sender names are already enriched:** the command resolves sender names automatically, so no extra lookup is required.
3. **Images are rendered as placeholders:** image messages appear as placeholders such as `[Image: img_xxx]`. Use `+messages-resources-download` when you need the binary resource.
3. **Images are rendered as placeholders:** image messages appear as placeholders such as `![Image](img_xxx)`. Use `+messages-resources-download` when you need the binary resource.
4. **Batching is more efficient:** fetching multiple IDs in one request is better than calling the API repeatedly.
## References

View File

@@ -152,7 +152,7 @@ lark-cli im +threads-messages-list --thread <thread_id>
## Resource Rendering
Search results reuse the same content formatter as other read commands. Image messages are rendered as placeholders such as `[Image: img_xxx]`; resource binaries are **not** downloaded automatically.
Search results reuse the same content formatter as other read commands. Image messages are rendered as placeholders such as `![Image](img_xxx)`; resource binaries are **not** downloaded automatically.
Use `im +messages-resources-download` if you need to fetch the underlying image or file bytes from a specific message.

View File

@@ -96,7 +96,7 @@ lark-cli im +threads-messages-list --thread omt_xxx --page-token <PAGE_TOKEN>
## Resource Rendering
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `[Image: img_xxx]`; by default resource binaries are **not** downloaded.
Thread replies are rendered into human-readable text. Image messages appear as placeholders such as `![Image](img_xxx)`; by default resource binaries are **not** downloaded.
Pass `--download-resources` to download every eligible resource (image/file/audio/video/media + post-embedded, excluding stickers) into `./lark-im-resources/` in one pass and attach a `resources` block to each reply (see [message enrichment](lark-im-message-enrichment.md#resource-auto-download---download-resources-opt-in)). Otherwise download individual resources manually through `im +messages-resources-download` (see [lark-im-messages-resources-download](lark-im-messages-resources-download.md)).

View File

@@ -18,16 +18,20 @@ metadata:
Shortcut 是对常用操作的高级封装(`lark-cli okr +<verb> [flags]`)。有 Shortcut 的操作优先使用。
| Shortcut | 说明 |
|--------------------------------------------------------------|--------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
| Shortcut | 说明 |
|----------------------------------------------------------------|--------------------------|
| [`+cycle-list`](references/lark-okr-cycle-list.md) | 获取特定用户的 OKR 周期列表,可以按时间筛选 |
| [`+cycle-detail`](references/lark-okr-cycle-detail.md) | 获取特定 OKR 中所有目标和关键结果的内容 |
| [`+progress-list`](references/lark-okr-progress-list.md) | 获取目标或关键结果的所有进展记录列表 |
| [`+progress-get`](references/lark-okr-progress-get.md) | 根据 ID 获取单条 OKR 进展记录 |
| [`+progress-create`](references/lark-okr-progress-create.md) | 为目标或关键结果创建进展记录 |
| [`+progress-update`](references/lark-okr-progress-update.md) | 更新指定 ID 的进展记录内容 |
| [`+progress-delete`](references/lark-okr-progress-delete.md) | 删除指定 ID 的进展记录(不可恢复) |
| [`+upload-image`](references/lark-okr-image-upload.md) | 上传图片用于 OKR 进展记录的富文本内容 |
| [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective 和 KR |
| [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 |
| [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 |
| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值 |
## 格式说明

View File

@@ -0,0 +1,106 @@
# okr +batch-create
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
批量创建 OKR 目标Objective和关键结果Key Result
## 推荐命令
```bash
# 批量创建 2 个 Objective各带 2 个 KR。
lark-cli okr +batch-create \
--cycle-id 7000000000000000001 \
--input '[
{
"text": "提升产品用户体验",
"mention": ["ou_xxxxxxxx"],
"krs": [
{"text": "页面加载速度提升 50%", "mention": ["ou_yyyyyyyy"]},
{"text": "用户满意度达到 4.8 分"}
]
},
{
"text": "拓展新市场份额",
"krs": [
{"text": "新增 10 个城市覆盖"},
{"text": "市场份额提升至 25%"}
]
}
]' \
--as user
# 从文件读取输入
lark-cli okr +batch-create \
--cycle-id 7000000000000000001 \
--input @okr_batch.json \
--as user
# 预览 API 调用Dry-run
lark-cli okr +batch-create \
--cycle-id 7000000000000000001 \
--input @okr_batch.json \
--dry-run \
--as user
```
- mention 是可选参数,不需要使用“@”提及其他用户时不传入。
- 传入的 mention 参数会以 @对应用户的形式,添加在文本后。
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------------|----|-----------|------------------------------------------------------------|
| `--cycle-id` | 是 | — | OKR 周期 IDint64 类型) |
| `--input` | 是 | — | JSON 数组格式的 Objective 列表。支持 `@文件路径` 从文件读取或 `@-` 从 stdin 读取。 |
| `--user-id-type` | 否 | `open_id` | mention 中使用的用户 ID 类型:`open_id` \| `union_id` \| `user_id` |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 输入格式
```json
[
{
"text": "Objective 内容",
"mention": ["ou_xxxxxxxx", "ou_yyyyyyyy"],
"krs": [
{
"text": "KR 内容",
"mention": ["ou_zzzzzzzz"]
}
]
}
]
```
## 工作流程
1. 使用 `+cycle-list` 获取可用的 OKR 周期 ID
2. 构造 `--input` JSON 数组,包含要创建的 Objective 和 KR
3. 执行 `lark-cli okr +batch-create --cycle-id <id> --input '...'`
## 输出
成功返回 JSON
```json
{
"ok": true,
"data": {
"created": [
{
"objective_id": "7000000000000000002",
"krs": ["7000000000000000003", "7000000000000000004"]
},
{
"objective_id": "7000000000000000005",
"krs": ["7000000000000000006"]
}
]
}
}
```
## 参考
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,80 @@
# okr +indicator-update
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
直接更新目标Objective或关键结果Key Result的指标当前值无需手动查询指标 ID。
> **查询指标:** 如需查看指标详情,请使用原生 API
> - 目标指标:`lark-cli okr objective.indicators list --objective-id <id>`
> - KR 指标:`lark-cli okr key_result.indicators list --key-result-id <id>`
## 推荐命令
```bash
# 更新 Objective 的指标值
lark-cli okr +indicator-update \
--level objective \
--id 7000000000000000001 \
--value 75.5 \
--as user
# 更新 Key Result 的指标值
lark-cli okr +indicator-update \
--level key-result \
--id 7000000000000000002 \
--value 100 \
--as user
```
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------|----|--------|--------------------------------------------------------------------|
| `--level` | 是 | — | 操作层级:`objective`(更新目标指标)\| `key-result`(更新 KR 指标) |
| `--id` | 是 | — | 目标 ID 或 KR IDint64 类型) |
| `--value` | 是 | — | 新的指标当前值(数字,范围:-99999999999 到 99999999999 |
| `--dry-run`| 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 工作流程
1. 使用 `+cycle-list``+cycle-detail` 获取目标 ID 或 KR ID。
2. 如需查看当前指标值,使用 `objective.indicators list``key_result.indicators list` 查询。
3. 执行 `+indicator-update` 指定层级、ID 和新值。
4. 命令自动查询指标 ID 并更新当前值。
## 输出
### JSON 格式
```json
{
"ok": true,
"data": {
"indicator_id": "7000000000000000003",
"current_value": 75.5,
"level": "objective",
"target_id": "7000000000000000001"
}
}
```
### 字段说明
| 字段 | 类型 | 说明 |
|----------------|--------|------------------------|
| `indicator_id` | string | 被更新的指标 ID |
| `current_value`| number | 更新后的指标当前值 |
| `level` | string | 操作层级:`objective` / `key-result` |
| `target_id` | string | 目标或 KR 的 ID |
## 注意事项
- 仅更新 `current_value` 字段,`unit``start_value``target_value` 等其他字段保持不变
- 若需要这些字段进行修改,使用原生接口 indicators.patch
- 指标的 `current_value_calculate_type` 必须为「手动更新」才能通过此命令修改。
## 参考
- [OKR 指标更新 API](https://open.feishu.cn/api-explorer?from=op_doc_tab&apiName=patch&project=okr&resource=okr.indicator&version=v2)
- [`lark-okr-progress-create.md`](./lark-okr-progress-create.md) — 创建进度记录
- [`lark-okr-cycle-detail.md`](./lark-okr-cycle-detail.md) — 查询周期详情获取 ID

View File

@@ -0,0 +1,81 @@
# okr +reorder
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
调整 OKR 周期下目标Objective或目标下关键结果Key Result的顺序。
## 推荐命令
```bash
# 调整 Objective 顺位
lark-cli okr +reorder \
--cycle-id 7000000000000000001 \
--level objective \
--ops '[
{"id": "7000000000000000002", "position": 2},
{"id": "7000000000000000003", "position": 1}
]' \
--as user
# 调整 KR 顺位(需指定 --objective-id
lark-cli okr +reorder \
--cycle-id 7000000000000000001 \
--level key-result \
--objective-id 7000000000000000002 \
--ops '[
{"id": "7000000000000000004", "position": 1},
{"id": "7000000000000000005", "position": 2}
]' \
--as user
# 从文件读取 ops
lark-cli okr +reorder \
--cycle-id 7000000000000000001 \
--level objective \
--ops @reorder_ops.json \
--as user
```
- 不允许将多个 objective/key-result 放在同一个位置下
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------------|----|--------|---------------------------------------------------------|
| `--level` | 是 | — | 调整层级:`objective`(调整周期下目标顺序)\| `key-result`(调整目标下 KR 顺序) |
| `--cycle-id` | 是 | — | OKR 周期 IDint64 类型)。 |
| `--objective-id` | 条件 | — | 目标 ID。当 `--level=key-result` 时**必填**,用于定位父目标。 |
| `--ops` | 是 | — | JSON 数组格式的顺位调整操作。支持 `@文件路径``@-` 从 stdin 读取。 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 工作流程
1. 使用 `+cycle-list``+cycle-detail` 获取周期 ID、目标 ID 和 KR ID。
2. 构造 `--ops` JSON 数组,指定要调整的 ID 和新 position执行命令。
3. 返回调整后的完整顺序。
## 输出
成功返回 JSON以调整 Objective 位置为例):
```json
{
"ok": true,
"data": {
"level": "objective",
"cycle_id": "7000000000000000001",
"total": 3,
"ordered": [
"7000000000000000003",
"7000000000000000002",
"7000000000000000004"
]
}
}
```
## 参考
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,96 @@
# okr +weight
> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
调整 OKR 周期下目标Objective或目标下关键结果Key Result的权重。支持部分指定权重未指定的按原权重比例自动分配。
## 推荐命令
```bash
# 调整 Objective 权重(部分指定,剩余自动分配)
lark-cli okr +weight \
--cycle-id 7000000000000000001 \
--level objective \
--weights '[
{"id": "7000000000000000002", "weight": 0.6},
{"id": "7000000000000000003", "weight": 0.3}
]' \
--as user
# 调整 KR 权重(全部指定,和为 1
lark-cli okr +weight \
--cycle-id 7000000000000000001 \
--level key-result \
--objective-id 7000000000000000002 \
--weights '[
{"id": "7000000000000000004", "weight": 0.6},
{"id": "7000000000000000005", "weight": 0.4}
]' \
--as user
# 从文件读取 weights
lark-cli okr +weight \
--cycle-id 7000000000000000001 \
--level objective \
--weights @weights.json \
--as user
```
参数限制: 请求中的权重保留三位小数,分配的所有权重和不能大于 1 (小于等于 1 是允许的)。
### 权重归一化
- 在 OKR 中,一个周期下所有 Objective 和 一个 Objective 下所有 Key Result 的权重和固定为 1.
- 在使用 +weight shortcut 分配 OKR 权重时,已分配的总权重不得超过 1。
- 若已分配的权重 < 1剩余的权重会按照原始权重的比例均分到未指定的 Objective/Key Result 下。
- 若所有 Objective/Key Result 均分配了权重但和 < 1剩余的权重会计算在最后一个 Objective/Key Result 下。
## 参数
| 参数 | 必填 | 默认值 | 说明 |
|------------------|----|--------|---------------------------------------------------------------------|
| `--level` | 是 | — | 调整层级:`objective`(调整周期下目标权重)\| `key-result`(调整目标下 KR 权重) |
| `--cycle-id` | 是 | — | OKR 周期 IDint64 类型) |
| `--objective-id` | 条件 | — | 目标 ID。当 `--level=key-result` 时**必填**,用于定位父目标。 |
| `--weights` | 是 | — | JSON 数组格式的权重分配。支持 `@文件路径``@-` 从 stdin 读取。权重保留三位小数,分配的所有权重和不能大于 1 |
| `--dry-run` | 否 | — | 预览 API 调用而不实际执行 |
| `--format` | 否 | `json` | 输出格式 |
## 工作流程
1. 使用 `+cycle-list``+cycle-detail` 获取周期 ID、目标 ID、KR ID 和当前权重。
2. 构造 `--weights` JSON 数组,指定要调整的 ID 和权重,执行命令。
3. 返回调整后的完整权重列表。
## 输出
成功返回 JSON
```json
{
"ok": true,
"data": {
"level": "objective",
"cycle_id": "7000000000000000001",
"total": 3,
"weights": [
{"id": "7000000000000000002", "weight": 0.6},
{"id": "7000000000000000003", "weight": 0.3},
{"id": "7000000000000000004", "weight": 0.1}
]
}
}
```
## 关于 1001001 错误
有时,即使输入的参数完全正确, +weight 也会返回 1001001 错误。这是因为你的租户设置中,不一定开启了目标或关键结果的设置权重功能。
若你确认输入的参数无误cycle-id/objective-id 正确weights 中的 id 均是同一个周期下的目标或同一个目标下的关键结果weights 中的权重和 <1,
不必进一步尝试,你需要向用户确认 OKR 应用目前是否开启了目标或关键结果的设置权重功能。
## 参考
- [OKR 业务实体](lark-okr-entities.md) -- OKR 实体结构定义
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -0,0 +1,456 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// --- Dry-run E2E tests for +batch-create, +reorder, +weight ---
// TestOKR_BatchCreateDryRun validates +batch-create dry-run output contains expected API paths.
func TestOKR_BatchCreateDryRun(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", "+batch-create",
"--cycle-id", "123456",
"--input", `[{"text":"Objective 1","krs":[{"text":"KR 1"}]}]`,
"--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 objective API path, got: %s", output)
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/"), "dry-run should contain KR API path prefix, got: %s", output)
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
}
// TestOKR_BatchCreateDryRun_WithUserIDType validates +batch-create dry-run with --user-id-type.
func TestOKR_BatchCreateDryRun_WithUserIDType(t *testing.T) {
setDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+batch-create",
"--cycle-id", "123456",
"--input", `[{"text":"Objective 1","krs":[{"text":"KR 1"}]}]`,
"--user-id-type", "user_id",
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "user_id"), "dry-run should contain user-id-type, got: %s", output)
}
// TestOKR_ReorderDryRun validates +reorder dry-run output contains expected API paths.
func TestOKR_ReorderDryRun(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", "+reorder",
"--cycle-id", "123456",
"--level", "objective",
"--ops", `[{"id":"obj_1","position":2},{"id":"obj_2","position":1}]`,
"--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 objective API path, got: %s", output)
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
}
// TestOKR_ReorderDryRun_KR validates +reorder dry-run with --level=key-result.
func TestOKR_ReorderDryRun_KR(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", "+reorder",
"--cycle-id", "123456",
"--objective-id", "789",
"--level", "key-result",
"--ops", `[{"id":"1001","position":2},{"id":"1002","position":1}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/789/key_results"), "dry-run should contain KR API path, got: %s", output)
assert.True(t, strings.Contains(output, "789"), "dry-run should contain objective-id, got: %s", output)
}
// TestOKR_WeightDryRun validates +weight dry-run output contains expected API paths.
func TestOKR_WeightDryRun(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", "+weight",
"--cycle-id", "123456",
"--level", "objective",
"--weights", `[{"id":"obj_1","weight":0.6},{"id":"obj_2","weight":0.4}]`,
"--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 objective API path, got: %s", output)
assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output)
}
// TestOKR_WeightDryRun_KR validates +weight dry-run with --level=key-result.
func TestOKR_WeightDryRun_KR(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", "+weight",
"--cycle-id", "123456",
"--objective-id", "789",
"--level", "key-result",
"--weights", `[{"id":"1001","weight":0.5},{"id":"1002","weight":0.5}]`,
"--dry-run",
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := result.Stdout
assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/789/key_results"), "dry-run should contain KR API path, got: %s", output)
assert.True(t, strings.Contains(output, "789"), "dry-run should contain objective-id, got: %s", output)
}
// --- Live E2E tests (require user token, skip otherwise) ---
// getTestCycleID returns the test cycle ID from env var, or skips the test.
func getTestCycleID(t *testing.T) string {
t.Helper()
cycleID := os.Getenv("OKR_TEST_CYCLE_ID")
if cycleID == "" {
t.Skip("OKR_TEST_CYCLE_ID not set; set to a valid cycle ID for live E2E tests")
}
return cycleID
}
// liveTestCreated tracks resources created during a live test for cleanup.
type liveTestCreated struct {
ObjectiveID string
KRIDs []string
}
// createTestObjectives creates test objectives using +batch-create and returns the created IDs.
func createTestObjectives(t *testing.T, ctx context.Context, cycleID string, suffix string) []liveTestCreated {
t.Helper()
input := []map[string]interface{}{
{
"text": fmt.Sprintf("E2E Test Objective A %s", suffix),
"krs": []map[string]interface{}{
{"text": fmt.Sprintf("E2E Test KR A1 %s", suffix)},
{"text": fmt.Sprintf("E2E Test KR A2 %s", suffix)},
},
},
{
"text": fmt.Sprintf("E2E Test Objective B %s", suffix),
"krs": []map[string]interface{}{
{"text": fmt.Sprintf("E2E Test KR B1 %s", suffix)},
},
},
}
inputJSON, _ := json.Marshal(input)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+batch-create",
"--cycle-id", cycleID,
"--input", string(inputJSON),
},
})
require.NoError(t, err, "failed to create test objectives")
result.AssertExitCode(t, 0)
var created []liveTestCreated
createdArr := gjson.Get(result.Stdout, "data.created").Array()
for _, obj := range createdArr {
objectiveID := obj.Get("objective_id").String()
var krIDs []string
for _, kr := range obj.Get("krs").Array() {
krIDs = append(krIDs, kr.String())
}
created = append(created, liveTestCreated{
ObjectiveID: objectiveID,
KRIDs: krIDs,
})
}
require.Len(t, created, 2, "expected 2 objectives created")
require.Len(t, created[0].KRIDs, 2, "expected 2 KRs for first objective")
require.Len(t, created[1].KRIDs, 1, "expected 1 KR for second objective")
require.NotEmpty(t, created[0].ObjectiveID, "objective_id should not be empty")
require.NotEmpty(t, created[0].KRIDs[0], "kr_id should not be empty")
return created
}
// cleanupLiveTest deletes KRs first, then objectives, using the raw API service commands.
func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
t.Helper()
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
// Delete in reverse order: KRs first, then objectives
for i := len(created) - 1; i >= 0; i-- {
obj := created[i]
// Delete KRs first (reverse order)
for j := len(obj.KRIDs) - 1; j >= 0; j-- {
krID := obj.KRIDs[j]
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"okr", "v2/key_results", "delete",
"--key-result-id", krID,
"--yes",
},
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete KR %s", krID), result, err)
select {
case <-cleanupCtx.Done():
return
case <-time.After(200 * time.Millisecond):
}
}
// Then delete the objective
result, err := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{
"okr", "v2/objectives", "delete",
"--objective-id", obj.ObjectiveID,
"--yes",
},
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete objective %s", obj.ObjectiveID), result, err)
if i > 0 {
select {
case <-cleanupCtx.Done():
return
case <-time.After(200 * time.Millisecond):
}
}
}
}
// TestOKR_BatchCreateLive validates +batch-create with real API calls: create, verify, cleanup.
func TestOKR_BatchCreateLive(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
cycleID := getTestCycleID(t)
suffix := clie2e.GenerateSuffix()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
// Create test objectives
created := createTestObjectives(t, ctx, cycleID, suffix)
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
t.Cleanup(func() {
cleanupLiveTest(t, created)
})
// Verify: call +cycle-detail to confirm objectives exist
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
foundCount := 0
for _, obj := range objectives {
objID := obj.Get("id").String()
for _, c := range created {
if objID == c.ObjectiveID {
foundCount++
// Verify KRs exist under this objective
krs := obj.Get("key_results").Array()
krIDs := make(map[string]bool)
for _, kr := range krs {
krIDs[kr.Get("id").String()] = true
}
for _, expectedKR := range c.KRIDs {
assert.True(t, krIDs[expectedKR], "expected KR %s to exist under objective %s", expectedKR, objID)
}
}
}
}
assert.Equal(t, len(created), foundCount, "all created objectives should be found in cycle detail")
}
// TestOKR_ReorderLive validates +reorder with real API calls: create, reorder, verify, cleanup.
func TestOKR_ReorderLive(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
cycleID := getTestCycleID(t)
suffix := clie2e.GenerateSuffix()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
// Create test objectives (A, then B)
created := createTestObjectives(t, ctx, cycleID, suffix)
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
t.Cleanup(func() {
cleanupLiveTest(t, created)
})
objA := created[0].ObjectiveID
objB := created[1].ObjectiveID
// Reorder: swap positions (B at position 1, A at position 2)
ops := []map[string]interface{}{
{"id": objB, "position": 1},
{"id": objA, "position": 2},
}
opsJSON, _ := json.Marshal(ops)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+reorder",
"--cycle-id", cycleID,
"--level", "objective",
"--ops", string(opsJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// Verify order via +cycle-detail
result, err = clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
var foundIDs []string
for _, obj := range objectives {
objID := obj.Get("id").String()
if objID == objA || objID == objB {
foundIDs = append(foundIDs, objID)
}
}
require.Len(t, foundIDs, 2, "should find both test objectives")
assert.Equal(t, objB, foundIDs[0], "after reorder, objective B should be first")
assert.Equal(t, objA, foundIDs[1], "after reorder, objective A should be second")
}
// TestOKR_WeightLive validates +weight with real API calls: create, set weights, verify sum=1.0, cleanup.
func TestOKR_WeightLive(t *testing.T) {
clie2e.SkipWithoutUserToken(t)
cycleID := getTestCycleID(t)
suffix := clie2e.GenerateSuffix()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.Cleanup(cancel)
// Create test objectives
created := createTestObjectives(t, ctx, cycleID, suffix)
// Register cleanup immediately after create to ensure resources are cleaned up even if later code fails
t.Cleanup(func() {
cleanupLiveTest(t, created)
})
objA := created[0].ObjectiveID
objB := created[1].ObjectiveID
// Set weights: A=0.6, B=0.4 (sum=1.0)
weights := []map[string]interface{}{
{"id": objA, "weight": 0.6},
{"id": objB, "weight": 0.4},
}
weightsJSON, _ := json.Marshal(weights)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+weight",
"--cycle-id", cycleID,
"--level", "objective",
"--weights", string(weightsJSON),
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
// Verify weights via +cycle-detail
result, err = clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
objectives := gjson.Get(result.Stdout, "data.objectives").Array()
var weightA, weightB float64
for _, obj := range objectives {
objID := obj.Get("id").String()
if objID == objA {
weightA = obj.Get("weight").Float()
} else if objID == objB {
weightB = obj.Get("weight").Float()
}
}
// Verify weights are set correctly (allowing for floating point tolerance)
assert.InDelta(t, 0.6, weightA, 0.001, "objective A weight should be 0.6")
assert.InDelta(t, 0.4, weightB, 0.001, "objective B weight should be 0.4")
// Verify sum = 1.0
sumWeights := weightA + weightB
assert.InDelta(t, 1.0, sumWeights, 0.001, "sum of weights should be 1.0, got %.6f", sumWeights)
}