diff --git a/shortcuts/okr/okr_batch_create.go b/shortcuts/okr/okr_batch_create.go index ff5fc1f0..61df0863 100644 --- a/shortcuts/okr/okr_batch_create.go +++ b/shortcuts/okr/okr_batch_create.go @@ -58,45 +58,9 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) { 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) + content := BuildContentBlock(obj.Text, obj.Mention) body := map[string]interface{}{ "content": content, } @@ -120,7 +84,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI // 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) + content := BuildContentBlock(kr.Text, kr.Mention) body := map[string]interface{}{ "content": content, } @@ -224,7 +188,7 @@ var OKRBatchCreate = common.Shortcut{ for i, obj := range objectives { // Objective creation - objContent := buildContentBlock(obj.Text, obj.Mention) + objContent := BuildContentBlock(obj.Text, obj.Mention) objBody := map[string]interface{}{ "content": objContent, } @@ -241,7 +205,7 @@ var OKRBatchCreate = common.Shortcut{ // KR creations for j, kr := range obj.KRs { - krContent := buildContentBlock(kr.Text, kr.Mention) + krContent := BuildContentBlock(kr.Text, kr.Mention) krBody := map[string]interface{}{ "content": krContent, } diff --git a/shortcuts/okr/okr_batch_create_test.go b/shortcuts/okr/okr_batch_create_test.go index 809ee9d3..3efa2e0c 100644 --- a/shortcuts/okr/okr_batch_create_test.go +++ b/shortcuts/okr/okr_batch_create_test.go @@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) { func TestBuildContentBlock(t *testing.T) { t.Parallel() - cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"}) + cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"}) if cb == nil { t.Fatal("expected non-nil ContentBlock") } diff --git a/shortcuts/okr/okr_cli_resp.go b/shortcuts/okr/okr_cli_resp.go index dc45a58d..1b026380 100644 --- a/shortcuts/okr/okr_cli_resp.go +++ b/shortcuts/okr/okr_cli_resp.go @@ -29,15 +29,10 @@ type RespCategory struct { // RespCycle 周期 type RespCycle struct { - ID string `json:"id"` - CreateTime string `json:"create_time"` - UpdateTime string `json:"update_time"` - TenantCycleID string `json:"tenant_cycle_id"` - Owner RespOwner `json:"owner"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - CycleStatus *string `json:"cycle_status,omitempty"` - Score *float64 `json:"score,omitempty"` + ID string `json:"id"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + CycleStatus *string `json:"cycle_status,omitempty"` } // RespIndicator 指标 @@ -152,3 +147,145 @@ type RespProgress struct { Content *string `json:"content,omitempty"` ProgressRate *RespProgressRate `json:"progress_rate,omitempty"` } + +// ========== Simple-style response types (semi-plain text format) ========== + +// RespKeyResultSimple is KeyResult response with SemiPlainContent instead of ContentBlock JSON string. +type RespKeyResultSimple struct { + ID string `json:"id"` + CreateTime string `json:"create_time"` + UpdateTime string `json:"update_time"` + Owner RespOwner `json:"owner"` + ObjectiveID string `json:"objective_id"` + Position *int32 `json:"position,omitempty"` + Content *SemiPlainContent `json:"content,omitempty"` + Score *float64 `json:"score,omitempty"` + Weight *float64 `json:"weight,omitempty"` + Deadline *string `json:"deadline,omitempty"` +} + +// RespObjectiveSimple is Objective response with SemiPlainContent instead of ContentBlock JSON string. +type RespObjectiveSimple struct { + ID string `json:"id"` + CreateTime string `json:"create_time"` + UpdateTime string `json:"update_time"` + Owner RespOwner `json:"owner"` + CycleID string `json:"cycle_id"` + Position *int32 `json:"position,omitempty"` + Content *SemiPlainContent `json:"content,omitempty"` + Score *float64 `json:"score,omitempty"` + Notes *SemiPlainContent `json:"notes,omitempty"` + Weight *float64 `json:"weight,omitempty"` + Deadline *string `json:"deadline,omitempty"` + CategoryID *string `json:"category_id,omitempty"` + KeyResults []RespKeyResultSimple `json:"key_results,omitempty"` +} + +// RespProgressSimple is Progress response with SemiPlainContent instead of ContentBlock JSON string. +type RespProgressSimple struct { + ID string `json:"progress_id"` + ModifyTime string `json:"modify_time"` + CreateTime *string `json:"create_time,omitempty"` + Content *SemiPlainContent `json:"content,omitempty"` + ProgressRate *RespProgressRate `json:"progress_rate,omitempty"` +} + +// ToSimple converts KeyResult to RespKeyResultSimple. +func (k *KeyResult) ToSimple() *RespKeyResultSimple { + if k == nil { + return nil + } + result := &RespKeyResultSimple{ + ID: k.ID, + CreateTime: formatTimestamp(k.CreateTime), + UpdateTime: formatTimestamp(k.UpdateTime), + Owner: *k.Owner.ToResp(), + ObjectiveID: k.ObjectiveID, + Position: k.Position, + Score: k.Score, + Weight: k.Weight, + } + if k.Deadline != nil { + d := formatTimestamp(*k.Deadline) + result.Deadline = &d + } + result.Content = k.Content.ToSemiPlain() + return result +} + +// ToSimple converts Objective to RespObjectiveSimple. +func (o *Objective) ToSimple() *RespObjectiveSimple { + if o == nil { + return nil + } + result := &RespObjectiveSimple{ + ID: o.ID, + CreateTime: formatTimestamp(o.CreateTime), + UpdateTime: formatTimestamp(o.UpdateTime), + Owner: *o.Owner.ToResp(), + CycleID: o.CycleID, + Position: o.Position, + Score: o.Score, + Weight: o.Weight, + CategoryID: o.CategoryID, + } + if o.Deadline != nil { + d := formatTimestamp(*o.Deadline) + result.Deadline = &d + } + result.Content = o.Content.ToSemiPlain() + result.Notes = o.Notes.ToSemiPlain() + return result +} + +// ToSimple converts ProgressV1 to RespProgressSimple. +func (p *ProgressV1) ToSimple() *RespProgressSimple { + if p == nil { + return nil + } + resp := &RespProgressSimple{ + ID: p.ID, + ModifyTime: formatTimestamp(p.ModifyTime), + } + if p.ProgressRate != nil { + resp.ProgressRate = &RespProgressRate{ + Percent: p.ProgressRate.Percent, + } + if p.ProgressRate.Status != nil { + s := ProgressStatus(*p.ProgressRate.Status).String() + if s != "" { + resp.ProgressRate.Status = &s + } + } + } + if p.Content != nil { + resp.Content = p.Content.ToV2().ToSemiPlain() + } + return resp +} + +// ToSimple converts Progress to RespProgressSimple. +func (p *Progress) ToSimple() *RespProgressSimple { + if p == nil { + return nil + } + createTime := formatTimestamp(p.CreateTime) + resp := &RespProgressSimple{ + ID: p.ID, + ModifyTime: formatTimestamp(p.UpdateTime), + CreateTime: &createTime, + } + if p.ProgressRate != nil { + resp.ProgressRate = &RespProgressRate{ + Percent: p.ProgressRate.ProgressPercent, + } + if p.ProgressRate.ProgressStatus != nil { + s := ProgressStatus(*p.ProgressRate.ProgressStatus).String() + if s != "" { + resp.ProgressRate.Status = &s + } + } + } + resp.Content = p.Content.ToSemiPlain() + return resp +} diff --git a/shortcuts/okr/okr_cycle_detail.go b/shortcuts/okr/okr_cycle_detail.go index 3839e1d1..41be7b23 100644 --- a/shortcuts/okr/okr_cycle_detail.go +++ b/shortcuts/okr/okr_cycle_detail.go @@ -26,6 +26,7 @@ var OKRCycleDetail = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true}, + {Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { cycleID := runtime.Str("cycle-id") @@ -35,6 +36,10 @@ var OKRCycleDetail = common.Shortcut{ 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") } + style := runtime.Str("style") + if style != "simple" && style != "richtext" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style") + } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -50,6 +55,7 @@ var OKRCycleDetail = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { cycleID := runtime.Str("cycle-id") + style := runtime.Str("style") // Paginate objectives under the cycle. queryParams := map[string]interface{}{"page_size": "100"} @@ -96,85 +102,106 @@ var OKRCycleDetail = common.Shortcut{ } // For each objective, paginate key results and convert to response format. - respObjectives := make([]*RespObjective, 0, len(objectives)) - for i := range objectives { - if err := ctx.Err(); err != nil { - return err - } - obj := &objectives[i] - - krQuery := map[string]interface{}{"page_size": "100"} - - var keyResults []KeyResult - krPage := 0 - for { + if style == "simple" { + respObjectives := make([]*RespObjectiveSimple, 0, len(objectives)) + for i := range objectives { if err := ctx.Err(); err != nil { return err } - if krPage > 0 { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(500 * time.Millisecond): - } - } - krPage++ + obj := &objectives[i] - path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID) - data, err := runtime.CallAPITyped("GET", path, krQuery, nil) + keyResults, err := fetchKeyResults(ctx, runtime, obj.ID) if err != nil { return err } - itemsRaw, _ := data["items"].([]interface{}) - for _, item := range itemsRaw { - raw, err := json.Marshal(item) - if err != nil { - continue + respObj := obj.ToSimple() + if respObj == nil { + continue + } + respKRs := make([]RespKeyResultSimple, 0, len(keyResults)) + for j := range keyResults { + if r := keyResults[j].ToSimple(); r != nil { + respKRs = append(respKRs, *r) } - var kr KeyResult - if err := json.Unmarshal(raw, &kr); err != nil { - continue + } + respObj.KeyResults = respKRs + respObjectives = append(respObjectives, respObj) + } + + result := map[string]interface{}{ + "cycle_id": cycleID, + "objectives": respObjectives, + "total": len(respObjectives), + "style": style, + } + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style) + for _, o := range respObjectives { + contentText := "" + if o.Content != nil { + contentText = o.Content.Text } - keyResults = append(keyResults, kr) + notesText := "" + if o.Notes != nil { + notesText = o.Notes.Text + } + fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, contentText, notesText, ptrFloat64(o.Score), ptrFloat64(o.Weight)) + for _, kr := range o.KeyResults { + krText := "" + if kr.Content != nil { + krText = kr.Content.Text + } + fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, krText, ptrFloat64(kr.Score), ptrFloat64(kr.Weight)) + } + } + }) + } else { + // richtext mode + respObjectives := make([]*RespObjective, 0, len(objectives)) + for i := range objectives { + if err := ctx.Err(); err != nil { + return err + } + obj := &objectives[i] + + keyResults, err := fetchKeyResults(ctx, runtime, obj.ID) + if err != nil { + return err } - hasMore, pageToken := common.PaginationMeta(data) - if !hasMore || pageToken == "" { - break + respObj := obj.ToResp() + if respObj == nil { + continue } - krQuery["page_token"] = pageToken + respKRs := make([]RespKeyResult, 0, len(keyResults)) + for j := range keyResults { + if r := keyResults[j].ToResp(); r != nil { + respKRs = append(respKRs, *r) + } + } + respObj.KeyResults = respKRs + respObjectives = append(respObjectives, respObj) } - respObj := obj.ToResp() - if respObj == nil { - continue + result := map[string]interface{}{ + "cycle_id": cycleID, + "objectives": respObjectives, + "total": len(respObjectives), + "style": style, } - respKRs := make([]RespKeyResult, 0, len(keyResults)) - for j := range keyResults { - if r := keyResults[j].ToResp(); r != nil { - respKRs = append(respKRs, *r) + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style) + for _, o := range respObjectives { + fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight)) + for _, kr := range o.KeyResults { + fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight)) + } } - } - respObj.KeyResults = respKRs - respObjectives = append(respObjectives, respObj) + }) } - - result := map[string]interface{}{ - "cycle_id": cycleID, - "objectives": respObjectives, - "total": len(respObjectives), - } - - runtime.OutFormat(result, nil, func(w io.Writer) { - fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives)) - for _, o := range respObjectives { - fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight)) - for _, kr := range o.KeyResults { - fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight)) - } - } - }) return nil }, } diff --git a/shortcuts/okr/okr_cycle_list.go b/shortcuts/okr/okr_cycle_list.go index c236b288..4055908c 100644 --- a/shortcuts/okr/okr_cycle_list.go +++ b/shortcuts/okr/okr_cycle_list.go @@ -46,12 +46,38 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool { if err1 != nil || err2 != nil { return false } - cycleStart := time.UnixMilli(startMs) - cycleEnd := time.UnixMilli(endMs) + cycleStart := time.UnixMilli(startMs).UTC() + cycleEnd := time.UnixMilli(endMs).UTC() // Two ranges overlap iff one starts before the other ends return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart) } +// isCurrentActiveCycle checks whether a cycle is currently active: +// - current time is within the cycle's start and end time +// - cycle status is default (0) or normal (1) +func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool { + startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64) + endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64) + if err1 != nil || err2 != nil { + return false + } + cycleStart := time.UnixMilli(startMs).UTC() + cycleEnd := time.UnixMilli(endMs).UTC() + nowUTC := now.UTC() + + // Check time range: now must be >= start and <= end + if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) { + return false + } + + // Check status: must be default or normal + if cycle.CycleStatus == nil { + return false + } + status := *cycle.CycleStatus + return status == CycleStatusDefault || status == CycleStatusNormal +} + var OKRListCycles = common.Shortcut{ Service: "okr", Command: "+cycle-list", @@ -175,14 +201,30 @@ var OKRListCycles = common.Shortcut{ respCycles = append(respCycles, filtered[i].ToResp()) } + // Filter current active cycles + now := time.Now() + currentActiveCycles := make([]*RespCycle, 0) + for i := range filtered { + if isCurrentActiveCycle(&filtered[i], now) { + currentActiveCycles = append(currentActiveCycles, filtered[i].ToResp()) + } + } + runtime.OutFormat(map[string]interface{}{ - "cycles": respCycles, - "total": len(respCycles), + "cycles": respCycles, + "total": len(respCycles), + "current_active_cycles": currentActiveCycles, }, nil, func(w io.Writer) { fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles)) for _, c := range respCycles { fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus)) } + if len(currentActiveCycles) > 0 { + fmt.Fprintf(w, "\nCurrent active cycle(s):\n") + for _, c := range currentActiveCycles { + fmt.Fprintf(w, " [%s] %s ~ %s\n", c.ID, c.StartTime, c.EndTime) + } + } }) return nil }, diff --git a/shortcuts/okr/okr_cycle_list_test.go b/shortcuts/okr/okr_cycle_list_test.go index 53b0a5c7..951cbcda 100644 --- a/shortcuts/okr/okr_cycle_list_test.go +++ b/shortcuts/okr/okr_cycle_list_test.go @@ -5,8 +5,10 @@ package okr import ( "bytes" + "strconv" "strings" "testing" + "time" "github.com/spf13/cobra" @@ -260,11 +262,156 @@ func TestCycleListExecute_NoCycles(t *testing.T) { if len(cycles) != 0 { t.Fatalf("cycles = %v, want empty", cycles) } + // Assert current_active_cycles field exists and is a slice + rawCurrentActive, ok := data["current_active_cycles"] + if !ok { + t.Fatal("current_active_cycles field is missing from response") + } + currentActive, ok := rawCurrentActive.([]interface{}) + if !ok { + t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive) + } + if len(currentActive) != 0 { + t.Fatalf("current_active_cycles = %v, want empty", currentActive) + } +} + +// --- isCurrentActiveCycle unit tests --- + +func TestIsCurrentActiveCycle(t *testing.T) { + t.Parallel() + now := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + cycle *Cycle + expected bool + }{ + { + name: "active cycle with normal status", + cycle: &Cycle{ + ID: "c1", + StartTime: "1767225600000", // 2026-01-01 + EndTime: "1798761599999", // 2026-12-31 23:59:59 + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: true, + }, + { + name: "active cycle with default status", + cycle: &Cycle{ + ID: "c2", + StartTime: "1767225600000", // 2026-01-01 + EndTime: "1798761599999", // 2026-12-31 + CycleStatus: CycleStatusDefault.Ptr(), + }, + expected: true, + }, + { + name: "cycle with invalid status", + cycle: &Cycle{ + ID: "c3", + StartTime: "1767225600000", // 2026-01-01 + EndTime: "1798761599999", // 2026-12-31 + CycleStatus: CycleStatusInvalid.Ptr(), + }, + expected: false, + }, + { + name: "cycle with hidden status", + cycle: &Cycle{ + ID: "c4", + StartTime: "1767225600000", // 2026-01-01 + EndTime: "1798761599999", // 2026-12-31 + CycleStatus: CycleStatusHidden.Ptr(), + }, + expected: false, + }, + { + name: "past cycle", + cycle: &Cycle{ + ID: "c5", + StartTime: "1704067200000", // 2024-01-01 + EndTime: "1719791999999", // 2024-06-30 + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: false, + }, + { + name: "future cycle", + cycle: &Cycle{ + ID: "c6", + StartTime: "1830297600000", // 2028-01-01 + EndTime: "1861833599999", // 2028-12-31 + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: false, + }, + { + name: "nil cycle status", + cycle: &Cycle{ + ID: "c7", + StartTime: "1767225600000", // 2026-01-01 + EndTime: "1798761599999", // 2026-12-31 + CycleStatus: nil, + }, + expected: false, + }, + { + name: "invalid start time", + cycle: &Cycle{ + ID: "c8", + StartTime: "invalid", + EndTime: "1798761599999", // 2026-12-31 + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: false, + }, + { + name: "exact start time boundary", + cycle: &Cycle{ + ID: "c9", + StartTime: "1782734400000", // 2026-06-29 12:00:00 UTC + EndTime: "1798761599000", // 2026-12-31 23:59:59 UTC + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: true, + }, + { + name: "exact end time boundary", + cycle: &Cycle{ + ID: "c10", + StartTime: "1767225600000", // 2026-01-01 00:00:00 UTC + EndTime: "1782734400000", // 2026-06-29 12:00:00 UTC + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isCurrentActiveCycle(tt.cycle, now) + if result != tt.expected { + t.Fatalf("isCurrentActiveCycle() = %v, want %v", result, tt.expected) + } + }) + } } func TestCycleListExecute_WithCycles(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t)) + + // Calculate timestamps relative to now to avoid test expiration + now := time.Now().UTC() + // Active cycle: 6 months before to 6 months after now + activeStartMs := now.AddDate(0, -6, 0).UnixMilli() + activeEndMs := now.AddDate(0, 6, 0).UnixMilli() + // Past cycle: 2 years before to 1.5 years before now + pastStartMs := now.AddDate(-2, 0, 0).UnixMilli() + pastEndMs := now.AddDate(-1, -6, 0).UnixMilli() + reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/okr/v2/cycles", @@ -274,19 +421,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) { "data": map[string]interface{}{ "items": []interface{}{ map[string]interface{}{ - "id": "cycle-1", - "start_time": "1735689600000", - "end_time": "1751318400000", - "cycle_status": 1, + "id": "cycle-active", + "start_time": strconv.FormatInt(activeStartMs, 10), + "end_time": strconv.FormatInt(activeEndMs, 10), + "cycle_status": 1, // normal "owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"}, "tenant_cycle_id": "tc-1", "score": 0.75, }, map[string]interface{}{ - "id": "cycle-2", - "start_time": "1704067200000", - "end_time": "1719792000000", - "cycle_status": 2, + "id": "cycle-past", + "start_time": strconv.FormatInt(pastStartMs, 10), + "end_time": strconv.FormatInt(pastEndMs, 10), + "cycle_status": 2, // invalid "owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"}, "tenant_cycle_id": "tc-2", "score": 0.5, @@ -311,6 +458,46 @@ func TestCycleListExecute_WithCycles(t *testing.T) { if int(total) != 2 { t.Fatalf("total = %v, want 2", total) } + + // Check current_active_cycles - should only contain cycle-active + rawCurrentActive, ok := data["current_active_cycles"] + if !ok { + t.Fatal("current_active_cycles field is missing from response") + } + currentActive, ok := rawCurrentActive.([]interface{}) + if !ok { + t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive) + } + if len(currentActive) != 1 { + t.Fatalf("current_active_cycles count = %d, want 1", len(currentActive)) + } + activeCycle, ok := currentActive[0].(map[string]interface{}) + if !ok { + t.Fatalf("current_active_cycles[0] is not a map, got %T", currentActive[0]) + } + if activeCycle["id"] != "cycle-active" { + t.Fatalf("current_active_cycles[0].id = %v, want cycle-active", activeCycle["id"]) + } + + // Verify removed fields are not present in the response + for _, c := range cycles { + cycleMap, _ := c.(map[string]interface{}) + if _, ok := cycleMap["create_time"]; ok { + t.Fatal("create_time should not be present in response") + } + if _, ok := cycleMap["update_time"]; ok { + t.Fatal("update_time should not be present in response") + } + if _, ok := cycleMap["tenant_cycle_id"]; ok { + t.Fatal("tenant_cycle_id should not be present in response") + } + if _, ok := cycleMap["owner"]; ok { + t.Fatal("owner should not be present in response") + } + if _, ok := cycleMap["score"]; ok { + t.Fatal("score should not be present in response") + } + } } func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) { diff --git a/shortcuts/okr/okr_openapi.go b/shortcuts/okr/okr_openapi.go index 32794030..4e65a37d 100644 --- a/shortcuts/okr/okr_openapi.go +++ b/shortcuts/okr/okr_openapi.go @@ -5,7 +5,9 @@ package okr import ( "encoding/json" + "regexp" "strconv" + "strings" "time" ) @@ -261,14 +263,9 @@ func (c *Cycle) ToResp() *RespCycle { return nil } resp := &RespCycle{ - ID: c.ID, - CreateTime: formatTimestamp(c.CreateTime), - UpdateTime: formatTimestamp(c.UpdateTime), - TenantCycleID: c.TenantCycleID, - Owner: *c.Owner.ToResp(), - StartTime: formatTimestamp(c.StartTime), - EndTime: formatTimestamp(c.EndTime), - Score: c.Score, + ID: c.ID, + StartTime: formatTimestamp(c.StartTime), + EndTime: formatTimestamp(c.EndTime), } if c.CycleStatus != nil { s := c.CycleStatus.ToString() @@ -733,6 +730,131 @@ func (p *ContentPersonV1) ToV2() *ContentMention { } } +// ========== SemiPlainContent (半纯文本格式) ========== + +// Regex patterns for semi-plain text processing (pre-compiled for performance). +var ( + placeholderRE = regexp.MustCompile(`\s*@\{[^}]+\}\s*`) + multiSpaceRE = regexp.MustCompile(`\s+`) +) + +// SemiPlainDoc represents a document link in semi-plain content. +type SemiPlainDoc struct { + Title string `json:"title"` + URL string `json:"url"` +} + +// SemiPlainContent is a simplified, lossy representation of ContentBlock. +// It contains plain text, mentions, docs, and images without rich formatting or position info. +type SemiPlainContent struct { + Text string `json:"text"` + Mention []string `json:"mention,omitempty"` + Docs []SemiPlainDoc `json:"docs,omitempty"` + Images []string `json:"images,omitempty"` +} + +// ToSemiPlain converts ContentBlock to SemiPlainContent (lossy conversion). +// Position information and formatting are discarded; only text, mentions, docs, and images are extracted. +func (c *ContentBlock) ToSemiPlain() *SemiPlainContent { + if c == nil { + return nil + } + result := &SemiPlainContent{} + var textParts []string + + for _, block := range c.Blocks { + if block.Paragraph != nil { + for _, elem := range block.Paragraph.Elements { + switch { + case elem.TextRun != nil && elem.TextRun.Text != nil: + textParts = append(textParts, *elem.TextRun.Text) + case elem.Mention != nil && elem.Mention.UserID != nil: + textParts = append(textParts, " @{"+*elem.Mention.UserID+"} ") + result.Mention = append(result.Mention, *elem.Mention.UserID) + case elem.DocsLink != nil: + doc := SemiPlainDoc{} + if elem.DocsLink.Title != nil { + doc.Title = *elem.DocsLink.Title + } + if elem.DocsLink.URL != nil { + doc.URL = *elem.DocsLink.URL + } + result.Docs = append(result.Docs, doc) + } + } + } + if block.Gallery != nil { + for _, img := range block.Gallery.Images { + if img.Src != nil { + result.Images = append(result.Images, *img.Src) + } + } + } + } + + result.Text = strings.Join(textParts, "") + return result +} + +// ToContentBlock converts SemiPlainContent to ContentBlock. +// Text and mentions are placed in a single paragraph (text first, then mentions). +// Docs and images are NOT converted (input semi-plain format only supports text+mention). +func (s *SemiPlainContent) ToContentBlock() *ContentBlock { + if s == nil { + return nil + } + elements := make([]ContentParagraphElement, 0, len(s.Mention)+1) + + // Strip @{userID} placeholders from text to avoid duplicate mentions + // (these placeholders are only for readability in the output format) + strippedText := placeholderRE.ReplaceAllString(s.Text, " ") + // Collapse multiple spaces and trim + strippedText = multiSpaceRE.ReplaceAllString(strippedText, " ") + strippedText = strings.TrimSpace(strippedText) + + // Add text element if stripped text is not empty + if strippedText != "" { + text := strippedText + elements = append(elements, ContentParagraphElement{ + ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), + TextRun: &ContentTextRun{ + Text: &text, + }, + }) + } + + // Add mention elements + for _, mention := range s.Mention { + m := mention + elements = append(elements, ContentParagraphElement{ + ParagraphElementType: ParagraphElementTypeMention.Ptr(), + Mention: &ContentMention{ + UserID: &m, + }, + }) + } + + return &ContentBlock{ + Blocks: []ContentBlockElement{ + { + BlockElementType: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraph{ + Elements: elements, + }, + }, + }, + } +} + +// BuildContentBlock converts text and mentions to a ContentBlock. +// This is a convenience wrapper around SemiPlainContent.ToContentBlock(). +func BuildContentBlock(text string, mentions []string) *ContentBlock { + return (&SemiPlainContent{ + Text: text, + Mention: mentions, + }).ToContentBlock() +} + // ProgressRateV1 进度率 type ProgressRateV1 struct { Percent *float64 `json:"percent,omitempty"` diff --git a/shortcuts/okr/okr_openapi_test.go b/shortcuts/okr/okr_openapi_test.go index d123dc92..01ace572 100644 --- a/shortcuts/okr/okr_openapi_test.go +++ b/shortcuts/okr/okr_openapi_test.go @@ -57,7 +57,9 @@ func TestToRespMethods(t *testing.T) { convey.So(resp, convey.ShouldNotBeNil) convey.So(resp.ID, convey.ShouldEqual, "cycle-id") convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal") - convey.So(*resp.Score, convey.ShouldEqual, 0.75) + // Verify removed fields are not present in RespCycle + convey.So(resp.StartTime, convey.ShouldNotBeEmpty) + convey.So(resp.EndTime, convey.ShouldNotBeEmpty) }) convey.Convey("Objective", func() { @@ -518,5 +520,449 @@ func float64Ptr(v float64) *float64 { return &v } // boolPtr returns a pointer to the given bool value. func boolPtr(v bool) *bool { return &v } +// ========== SemiPlainContent Conversion Tests ========== + +func TestContentBlockToSemiPlain_TextOnly(t *testing.T) { + t.Parallel() + cb := &ContentBlock{ + Blocks: []ContentBlockElement{ + { + BlockElementType: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraph{ + Elements: []ContentParagraphElement{ + { + ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), + TextRun: &ContentTextRun{ + Text: strPtr("Hello world"), + }, + }, + }, + }, + }, + }, + } + sp := cb.ToSemiPlain() + if sp == nil { + t.Fatal("expected non-nil SemiPlainContent") + } + if sp.Text != "Hello world" { + t.Fatalf("expected text 'Hello world', got '%s'", sp.Text) + } + if len(sp.Mention) != 0 { + t.Fatalf("expected 0 mentions, got %d", len(sp.Mention)) + } + if len(sp.Docs) != 0 { + t.Fatalf("expected 0 docs, got %d", len(sp.Docs)) + } + if len(sp.Images) != 0 { + t.Fatalf("expected 0 images, got %d", len(sp.Images)) + } +} + +func TestContentBlockToSemiPlain_WithMention(t *testing.T) { + t.Parallel() + cb := &ContentBlock{ + Blocks: []ContentBlockElement{ + { + BlockElementType: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraph{ + Elements: []ContentParagraphElement{ + { + ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), + TextRun: &ContentTextRun{ + Text: strPtr("Hello "), + }, + }, + { + ParagraphElementType: ParagraphElementTypeMention.Ptr(), + Mention: &ContentMention{ + UserID: strPtr("ou_123"), + }, + }, + { + ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), + TextRun: &ContentTextRun{ + Text: strPtr(", how are you?"), + }, + }, + }, + }, + }, + }, + } + sp := cb.ToSemiPlain() + if sp == nil { + t.Fatal("expected non-nil SemiPlainContent") + } + // Text includes @{userID} placeholder to preserve positional context + if sp.Text != "Hello @{ou_123} , how are you?" { + t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text) + } + if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" { + t.Fatalf("expected mention [ou_123], got %v", sp.Mention) + } +} + +func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) { + t.Parallel() + cb := &ContentBlock{ + Blocks: []ContentBlockElement{ + { + BlockElementType: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraph{ + Elements: []ContentParagraphElement{ + { + ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), + TextRun: &ContentTextRun{ + Text: strPtr("Check out this doc: "), + }, + }, + { + ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(), + DocsLink: &ContentDocsLink{ + Title: strPtr("Design Doc"), + URL: strPtr("https://example.feishu.cn/docx/xxx"), + }, + }, + }, + }, + }, + { + BlockElementType: BlockElementTypeGallery.Ptr(), + Gallery: &ContentGallery{ + Images: []ContentImageItem{ + { + Src: strPtr("https://example.com/img1.png"), + }, + { + Src: strPtr("https://example.com/img2.png"), + }, + }, + }, + }, + }, + } + sp := cb.ToSemiPlain() + if sp == nil { + t.Fatal("expected non-nil SemiPlainContent") + } + if sp.Text != "Check out this doc: " { + t.Fatalf("unexpected text: '%s'", sp.Text) + } + if len(sp.Docs) != 1 { + t.Fatalf("expected 1 doc, got %d", len(sp.Docs)) + } + if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" { + t.Fatalf("unexpected doc: %+v", sp.Docs[0]) + } + if len(sp.Images) != 2 { + t.Fatalf("expected 2 images, got %d", len(sp.Images)) + } + if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" { + t.Fatalf("unexpected images: %v", sp.Images) + } +} + +func TestContentBlockToSemiPlain_Nil(t *testing.T) { + t.Parallel() + var cb *ContentBlock + sp := cb.ToSemiPlain() + if sp != nil { + t.Fatal("expected nil SemiPlainContent for nil ContentBlock") + } +} + +func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) { + t.Parallel() + sp := &SemiPlainContent{ + Text: "Hello world", + } + cb := sp.ToContentBlock() + 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.Fatal("expected paragraph block") + } + if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 { + t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements)) + } + elem := block.Paragraph.Elements[0] + if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun { + t.Fatal("expected textRun element") + } + if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" { + t.Fatalf("unexpected text: %v", elem.TextRun) + } +} + +func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) { + t.Parallel() + sp := &SemiPlainContent{ + Text: "Please review", + Mention: []string{"ou_123", "ou_456"}, + } + cb := sp.ToContentBlock() + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + if len(cb.Blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(cb.Blocks)) + } + elems := cb.Blocks[0].Paragraph.Elements + if len(elems) != 3 { + t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems)) + } + if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" { + t.Fatal("unexpected first element") + } + if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" { + t.Fatal("unexpected second element") + } + if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" { + t.Fatal("unexpected third element") + } +} + +func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) { + t.Parallel() + sp := &SemiPlainContent{ + Text: " ", + Mention: []string{"ou_123"}, + } + cb := sp.ToContentBlock() + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + elems := cb.Blocks[0].Paragraph.Elements + // Empty text should be skipped, only mention remains + if len(elems) != 1 { + t.Fatalf("expected 1 element (mention only), got %d", len(elems)) + } + if *elems[0].ParagraphElementType != ParagraphElementTypeMention { + t.Fatal("expected mention element") + } +} + +func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) { + t.Parallel() + sp := &SemiPlainContent{ + Text: "Test", + Mention: []string{"ou_123"}, + Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}}, + Images: []string{"https://img.png"}, + } + cb := sp.ToContentBlock() + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + elems := cb.Blocks[0].Paragraph.Elements + // Docs and images are ignored in input conversion + if len(elems) != 2 { + t.Fatalf("expected 2 elements (text + mention), got %d", len(elems)) + } +} + +func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) { + t.Parallel() + // Simulate round-trip: output format has @{userID} in text, + // input conversion should strip them to avoid duplicate mentions + sp := &SemiPlainContent{ + Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ", + Mention: []string{"ou_zhangsan", "ou_lisi"}, + } + cb := sp.ToContentBlock() + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + elems := cb.Blocks[0].Paragraph.Elements + // Should have 3 elements: 1 text (stripped) + 2 mentions + if len(elems) != 3 { + t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems)) + } + // Text should have placeholders stripped + if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun { + t.Fatal("expected first element to be textRun") + } + // Note: space before comma is preserved from the placeholder's trailing space + expectedText := "任务一 ,任务二" + if *elems[0].TextRun.Text != expectedText { + t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text) + } + // Mentions should be preserved as separate elements + if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" { + t.Fatal("unexpected second element") + } + if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" { + t.Fatal("unexpected third element") + } +} + +func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) { + t.Parallel() + // Text that is only placeholders should result in no text element + sp := &SemiPlainContent{ + Text: " @{ou_123} @{ou_456} ", + Mention: []string{"ou_123", "ou_456"}, + } + cb := sp.ToContentBlock() + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + elems := cb.Blocks[0].Paragraph.Elements + // Should have only 2 mention elements, no text element + if len(elems) != 2 { + t.Fatalf("expected 2 elements (mentions only), got %d", len(elems)) + } + if *elems[0].ParagraphElementType != ParagraphElementTypeMention { + t.Fatal("expected first element to be mention") + } + if *elems[1].ParagraphElementType != ParagraphElementTypeMention { + t.Fatal("expected second element to be mention") + } +} + +func TestSemiPlainContentToContentBlock_Nil(t *testing.T) { + t.Parallel() + var sp *SemiPlainContent + cb := sp.ToContentBlock() + if cb != nil { + t.Fatal("expected nil ContentBlock for nil SemiPlainContent") + } +} + +func TestBuildContentBlock_Conversion(t *testing.T) { + t.Parallel() + cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"}) + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + elems := cb.Blocks[0].Paragraph.Elements + if len(elems) != 3 { + t.Fatalf("expected 3 elements, got %d", len(elems)) + } + if *elems[0].TextRun.Text != "Test text" { + t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text) + } + if *elems[1].Mention.UserID != "ou_123" { + t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID) + } + if *elems[2].Mention.UserID != "ou_456" { + t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID) + } +} + +func TestToSimpleMethods(t *testing.T) { + t.Parallel() + + // Test Objective.ToSimple() + text := "Objective text" + obj := &Objective{ + ID: "obj-1", + Content: BuildContentBlock(text, []string{"ou_123"}), + Notes: BuildContentBlock("Note text", nil), + Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")}, + CycleID: "cycle-1", + Score: float64Ptr(0.7), + Weight: float64Ptr(0.5), + Deadline: strPtr("1735776000000"), + } + simpleObj := obj.ToSimple() + if simpleObj == nil { + t.Fatal("expected non-nil RespObjectiveSimple") + } + if simpleObj.ID != "obj-1" { + t.Fatalf("expected ID obj-1, got %s", simpleObj.ID) + } + // Text includes @{userID} placeholder for positional context + expectedContentText := "Objective text @{ou_123} " + if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText { + t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text) + } + if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" { + t.Fatalf("unexpected notes: %+v", simpleObj.Notes) + } + if simpleObj.Score == nil || *simpleObj.Score != 0.7 { + t.Fatalf("unexpected score: %v", simpleObj.Score) + } + if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" { + t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention) + } + + // Test KeyResult.ToSimple() + kr := &KeyResult{ + ID: "kr-1", + ObjectiveID: "obj-1", + Content: BuildContentBlock("KR text", nil), + Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")}, + Score: float64Ptr(0.5), + } + simpleKR := kr.ToSimple() + if simpleKR == nil { + t.Fatal("expected non-nil RespKeyResultSimple") + } + if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" { + t.Fatalf("unexpected KR content: %+v", simpleKR.Content) + } + + // Test ProgressV1.ToSimple() + progress := &ProgressV1{ + ID: "prog-1", + ModifyTime: "1735776000000", + Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(), + } + simpleProgress := progress.ToSimple() + if simpleProgress == nil { + t.Fatal("expected non-nil RespProgressSimple") + } + // Text includes @{userID} placeholder for positional context + expectedProgressText := "Progress text @{ou_mention} " + if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText { + t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text) + } + if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" { + t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention) + } + + // Test Progress.ToSimple() (V2 progress record) + progressV2 := &Progress{ + ID: "prog-v2-1", + CreateTime: "1735689600000", + UpdateTime: "1735776000000", + Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}), + ProgressRate: &ProgressRate{ + ProgressPercent: float64Ptr(80.0), + ProgressStatus: int32Ptr(int32(ProgressStatusDone)), + }, + } + simpleProgressV2 := progressV2.ToSimple() + if simpleProgressV2 == nil { + t.Fatal("expected non-nil RespProgressSimple for Progress V2") + } + if simpleProgressV2.ID != "prog-v2-1" { + t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID) + } + if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" { + t.Fatal("expected non-empty CreateTime for Progress V2") + } + expectedV2Text := "V2 progress text @{ou_v2_mention} " + if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text { + t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text) + } + if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" { + t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate) + } + if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 { + t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent) + } + if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" { + t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention) + } +} + // listTypePtr returns a pointer to the given ListType value. func listTypePtr(v ListType) *ListType { return &v } diff --git a/shortcuts/okr/okr_patch.go b/shortcuts/okr/okr_patch.go new file mode 100644 index 00000000..2ccb21ab --- /dev/null +++ b/shortcuts/okr/okr_patch.go @@ -0,0 +1,311 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// patchParams holds the parsed parameters for the patch operation. +type patchParams struct { + Level string + TargetID string + Style string + Content *ContentBlock + Notes *ContentBlock + Score *float64 + Deadline *string + UserIDType string +} + +// parsePatchParams parses and validates flags from runtime into request-ready parameters. +func parsePatchParams(runtime *common.RuntimeContext) (*patchParams, error) { + p := &patchParams{ + Level: runtime.Str("level"), + TargetID: runtime.Str("target-id"), + Style: runtime.Str("style"), + UserIDType: runtime.Str("user-id-type"), + } + + hasField := false + + // Parse content if provided + if contentStr := runtime.Str("content"); contentStr != "" { + hasField = true + if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil { + return nil, err + } + if p.Style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(contentStr), &sp); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content") + } + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content") + } + } + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content") + } + p.Content = sp.ToContentBlock() + } else { + var cb ContentBlock + if err := json.Unmarshal([]byte(contentStr), &cb); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + } + p.Content = &cb + } + } + + // Parse notes if provided (only for objective) + if notesStr := runtime.Str("notes"); notesStr != "" { + hasField = true + if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil { + return nil, err + } + if p.Level != "objective" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported for level=objective").WithParam("--notes") + } + if p.Style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(notesStr), &sp); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--notes").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes text is required and cannot be empty").WithParam("--notes") + } + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes mention[%d] cannot be empty", i).WithParam("--notes") + } + } + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--notes") + } + p.Notes = sp.ToContentBlock() + } else { + var cb ContentBlock + if err := json.Unmarshal([]byte(notesStr), &cb); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid ContentBlock JSON: %s", err).WithParam("--notes").WithCause(err) + } + p.Notes = &cb + } + } + + // Parse score if provided + if scoreStr := runtime.Str("score"); scoreStr != "" { + hasField = true + score, err := strconv.ParseFloat(scoreStr, 64) + if err != nil || math.IsNaN(score) || math.IsInf(score, 0) { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be a valid number").WithParam("--score") + } + if score < 0 || score > 1 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be between 0 and 1").WithParam("--score") + } + // Check for exactly one decimal place + scoreStrTrimmed := strings.TrimRight(strings.TrimRight(scoreStr, "0"), ".") + parts := strings.Split(scoreStrTrimmed, ".") + if len(parts) == 2 && len(parts[1]) > 1 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must have at most one decimal place (e.g., 0.5, not 0.51)").WithParam("--score") + } + // Validation ensures at most one decimal place, so score is already correctly formatted + p.Score = &score + } + + // Parse deadline if provided + if deadlineStr := runtime.Str("deadline"); deadlineStr != "" { + hasField = true + deadlineMs, err := strconv.ParseInt(deadlineStr, 10, 64) + if err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a valid millisecond timestamp (integer)").WithParam("--deadline") + } + if deadlineMs <= 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a positive millisecond timestamp").WithParam("--deadline") + } + // Reject non-millisecond timestamps: year 2000 in ms is ~946e9, year 2100 in ms is ~4.1e12 + // Anything less than 1e12 is likely seconds or a wrong unit + if deadlineMs < 1000000000000 { // 1e12 ms = year ~33658, so use 1e12 as lower bound for reasonable ms timestamps + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a millisecond timestamp (13 digits), not seconds").WithParam("--deadline") + } + p.Deadline = &deadlineStr + } + + // At least one field must be provided + if !hasField { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --content, --notes, --score, or --deadline must be provided") + } + + return p, nil +} + +// OKRPatch patches an objective or key result. +var OKRPatch = common.Shortcut{ + Service: "okr", + Command: "+patch", + Description: "Patch an OKR objective or key result (content, notes, score, deadline)", + Risk: "write", + Scopes: []string{"okr:okr.content:writeonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "level", Desc: "patch level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}}, + {Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true}, + {Name: "style", Default: "simple", Desc: "input style for content/notes: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}}, + {Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}}, + {Name: "notes", Desc: "notes (objective only): semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}}, + {Name: "score", Desc: "score value between 0 and 1, with at most one decimal place (e.g., 0.5)"}, + {Name: "deadline", Desc: "deadline as millisecond timestamp"}, + {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 { + 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") + } + + targetID := runtime.Str("target-id") + if targetID == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id") + } + if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil { + return err + } + if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id") + } + + style := runtime.Str("style") + if style != "simple" && style != "richtext" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style") + } + + 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") + } + + // Delegate content/notes/score/deadline validation to parsePatchParams + if _, err := parsePatchParams(runtime); err != nil { + return err + } + + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + p, err := parsePatchParams(runtime) + if err != nil { + return common.NewDryRunAPI(). + PATCH(""). + Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error())) + } + + body := make(map[string]interface{}) + if p.Content != nil { + body["content"] = p.Content + } + if p.Notes != nil { + body["notes"] = p.Notes + } + if p.Score != nil { + body["score"] = *p.Score + } + if p.Deadline != nil { + body["deadline"] = *p.Deadline + } + + params := map[string]interface{}{ + "user_id_type": p.UserIDType, + } + + api := common.NewDryRunAPI() + if p.Level == "objective" { + api = api.PATCH("/open-apis/okr/v2/objectives/:objective_id"). + Set("objective_id", p.TargetID) + } else { + api = api.PATCH("/open-apis/okr/v2/key_results/:key_result_id"). + Set("key_result_id", p.TargetID) + } + return api.Params(params).Body(body). + Desc(fmt.Sprintf("Patch OKR %s: content=%v, notes=%v, score=%v, deadline=%v", + p.Level, p.Content != nil, p.Notes != nil, p.Score != nil, p.Deadline != nil)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + p, err := parsePatchParams(runtime) + if err != nil { + return err + } + + body := make(map[string]interface{}) + if p.Content != nil { + body["content"] = p.Content + } + if p.Notes != nil { + body["notes"] = p.Notes + } + if p.Score != nil { + body["score"] = *p.Score + } + if p.Deadline != nil { + body["deadline"] = *p.Deadline + } + + queryParams := map[string]interface{}{ + "user_id_type": p.UserIDType, + } + + var path string + if p.Level == "objective" { + path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s", p.TargetID) + } else { + path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s", p.TargetID) + } + + _, err = runtime.CallAPITyped("PATCH", path, queryParams, body) + if err != nil { + return wrapOkrNetworkErr(err, "failed to patch OKR %s", p.Level) + } + + result := map[string]interface{}{ + "level": p.Level, + "target_id": p.TargetID, + "patched": map[string]bool{ + "content": p.Content != nil, + "notes": p.Notes != nil, + "score": p.Score != nil, + "deadline": p.Deadline != nil, + }, + } + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Patched OKR %s [%s]\n", p.Level, p.TargetID) + if p.Content != nil { + fmt.Fprintf(w, " - content: updated\n") + } + if p.Notes != nil { + fmt.Fprintf(w, " - notes: updated\n") + } + if p.Score != nil { + fmt.Fprintf(w, " - score: %.1f\n", *p.Score) + } + if p.Deadline != nil { + fmt.Fprintf(w, " - deadline: %s\n", formatTimestamp(*p.Deadline)) + } + }) + + return nil + }, +} diff --git a/shortcuts/okr/okr_patch_test.go b/shortcuts/okr/okr_patch_test.go new file mode 100644 index 00000000..937bffa5 --- /dev/null +++ b/shortcuts/okr/okr_patch_test.go @@ -0,0 +1,1350 @@ +// 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 patchTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + return &core.CliConfig{ + AppID: "dummy", + AppSecret: "dummy", + Brand: core.BrandFeishu, + } +} + +func runPatchShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + parent := &cobra.Command{Use: "okr"} + OKRPatch.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// --- Validate tests --- + +func TestPatchValidate_MissingLevel(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--target-id", "123", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected --level required error") + } + // Cobra required flag errors are not our typed errors, so check message + if !strings.Contains(err.Error(), "level") { + t.Fatalf("expected --level required error, got: %v", err) + } +} + +func TestPatchValidate_MissingTargetID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected --target-id required error") + } + // Cobra required flag errors are not our typed errors, so check message + if !strings.Contains(err.Error(), "target-id") { + t.Fatalf("expected --target-id required error, got: %v", err) + } +} + +func TestPatchValidate_InvalidLevel(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "invalid", + "--target-id", "123", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for invalid level") + } + _, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--level" { + t.Fatalf("expected param --level, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidTargetID_NonNumeric(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "not-a-number", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for invalid target-id") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--target-id" { + t.Fatalf("expected param --target-id, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidTargetID_Negative(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "-1", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for negative target-id") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--target-id" { + t.Fatalf("expected param --target-id, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidTargetID_Zero(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "0", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for zero target-id") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--target-id" { + t.Fatalf("expected param --target-id, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidStyle(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "invalid", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for invalid style") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--style" { + t.Fatalf("expected param --style, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidUserIDType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--content", validSemiPlainJSON, + "--user-id-type", "invalid", + }) + if err == nil { + t.Fatal("expected error for invalid user-id-type") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--user-id-type" { + t.Fatalf("expected param --user-id-type, got %q", validationErr.Param) + } +} + +func TestPatchValidate_NoFieldsProvided(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + }) + if err == nil { + t.Fatal("expected error for no fields provided") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "" { + t.Fatalf("expected empty param (error not tied to a specific field), got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "at least one of") { + t.Fatalf("expected 'at least one of' error message, got: %v", err) + } +} + +func TestPatchValidate_InvalidContent_SimpleStyle(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", "not-json", + }) + if err == nil { + t.Fatal("expected error for invalid --content JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "semi-plain JSON") { + t.Fatalf("expected semi-plain JSON error, got: %v", err) + } +} + +func TestPatchValidate_InvalidContent_RichTextStyle(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "richtext", + "--content", "not-json", + }) + if err == nil { + t.Fatal("expected error for invalid --content JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "ContentBlock JSON") { + t.Fatalf("expected ContentBlock JSON error, got: %v", err) + } +} + +func TestPatchValidate_SemiPlainContent_EmptyText(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", `{"text":" ","mention":[]}`, + }) + if err == nil { + t.Fatal("expected error for empty text in content") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "text is required") { + t.Fatalf("expected text required error, got: %v", err) + } +} + +func TestPatchValidate_SemiPlainContent_EmptyMention(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", `{"text":"hello","mention":[""]}`, + }) + if err == nil { + t.Fatal("expected error for empty mention in content") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "mention[0] cannot be empty") { + t.Fatalf("expected mention empty error, got: %v", err) + } +} + +func TestPatchValidate_SemiPlainContent_WithDocs(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", `{"text":"hello","docs":[{"title":"doc","url":"https://example.com"}]}`, + }) + if err == nil { + t.Fatal("expected error for docs in simple style content") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "docs and images are not supported") { + t.Fatalf("expected docs/images not supported error, got: %v", err) + } +} + +func TestPatchValidate_SemiPlainContent_WithImages(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", `{"text":"hello","images":["https://example.com/img.png"]}`, + }) + if err == nil { + t.Fatal("expected error for images in simple style content") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "docs and images are not supported") { + t.Fatalf("expected docs/images not supported error, got: %v", err) + } +} + +func TestPatchValidate_NotesForbiddenOnKeyResult(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "123", + "--notes", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for notes on key-result") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--notes" { + t.Fatalf("expected param --notes, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "only supported for level=objective") { + t.Fatalf("expected notes only for objective error, got: %v", err) + } +} + +func TestPatchValidate_InvalidNotes_SimpleStyle(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--notes", "not-json", + }) + if err == nil { + t.Fatal("expected error for invalid --notes JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--notes" { + t.Fatalf("expected param --notes, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidNotes_RichTextStyle(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "richtext", + "--notes", "not-json", + }) + if err == nil { + t.Fatal("expected error for invalid --notes JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--notes" { + t.Fatalf("expected param --notes, got %q", validationErr.Param) + } +} + +func TestPatchValidate_SemiPlainNotes_EmptyText(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--notes", `{"text":" "}`, + }) + if err == nil { + t.Fatal("expected error for empty text in notes") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--notes" { + t.Fatalf("expected param --notes, got %q", validationErr.Param) + } +} + +func TestPatchValidate_SemiPlainNotes_EmptyMention(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--notes", `{"text":"hello","mention":[" "]}`, + }) + if err == nil { + t.Fatal("expected error for empty mention in notes") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--notes" { + t.Fatalf("expected param --notes, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidScore_NonNumeric(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "not-a-number", + }) + if err == nil { + t.Fatal("expected error for invalid score") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--score" { + t.Fatalf("expected param --score, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidScore_OutOfRange(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "1.5", + }) + if err == nil { + t.Fatal("expected error for score out of range") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--score" { + t.Fatalf("expected param --score, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "between 0 and 1") { + t.Fatalf("expected between 0 and 1 error, got: %v", err) + } +} + +func TestPatchValidate_InvalidScore_Negative(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "-0.1", + }) + if err == nil { + t.Fatal("expected error for negative score") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--score" { + t.Fatalf("expected param --score, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidScore_TooManyDecimals(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "0.51", + }) + if err == nil { + t.Fatal("expected error for score with too many decimals") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--score" { + t.Fatalf("expected param --score, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "at most one decimal place") { + t.Fatalf("expected one decimal place error, got: %v", err) + } +} + +func TestPatchValidate_InvalidDeadline(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--deadline", "not-a-number", + }) + if err == nil { + t.Fatal("expected error for invalid deadline") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--deadline" { + t.Fatalf("expected param --deadline, got %q", validationErr.Param) + } +} + +func TestPatchValidate_Valid_Objective_SimpleStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", validSemiPlainJSON, + "--notes", validSemiPlainJSON, + "--score", "0.5", + "--deadline", "1735776000000", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_Objective_RichTextStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "richtext", + "--content", validContentBlockJSON, + "--notes", validContentBlockJSON, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_KeyResult_SimpleStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/key_results/456", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "456", + "--style", "simple", + "--content", validSemiPlainJSON, + "--score", "1.0", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_KeyResult_RichTextStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/key_results/456", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "456", + "--style", "richtext", + "--content", validContentBlockJSON, + "--deadline", "1735776000000", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_ScoreBoundaryValues(t *testing.T) { + t.Parallel() + tests := []string{"0", "0.0", "1", "1.0", "0.3", "0.7"} + for _, score := range tests { + t.Run(score, func(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", score, + }) + if err != nil { + t.Fatalf("unexpected error for score %q: %v", score, err) + } + }) + } +} + +func TestPatchValidate_Valid_DefaultStyleIsSimple(t *testing.T) { + t.Parallel() + // Default style is simple, so passing semi-plain JSON without --style should work + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--content", validSemiPlainJSON, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_OnlyScore(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "0.0", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_OnlyDeadline(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--deadline", "1735776000000", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- DryRun tests --- + +func TestPatchDryRun_Objective_Content(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", validSemiPlainJSON, + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "PATCH") { + t.Fatalf("expected PATCH method in dry-run output, got: %s", output) + } + if !strings.Contains(output, "/open-apis/okr/v2/objectives/123") { + t.Fatalf("expected objective URL in dry-run output, got: %s", output) + } + if !strings.Contains(output, "content=true") { + t.Fatalf("expected content=true in dry-run output, got: %s", output) + } +} + +func TestPatchDryRun_KeyResult_Score(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "456", + "--score", "0.7", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "PATCH") { + t.Fatalf("expected PATCH method in dry-run output, got: %s", output) + } + if !strings.Contains(output, "/open-apis/okr/v2/key_results/456") { + t.Fatalf("expected key_result URL in dry-run output, got: %s", output) + } + if !strings.Contains(output, "score=true") { + t.Fatalf("expected score=true in dry-run output, got: %s", output) + } +} + +func TestPatchDryRun_Objective_MultipleFields(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "789", + "--style", "simple", + "--content", validSemiPlainJSON, + "--notes", validSemiPlainJSON, + "--score", "0.5", + "--deadline", "1735776000000", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "content=true") || + !strings.Contains(output, "notes=true") || + !strings.Contains(output, "score=true") || + !strings.Contains(output, "deadline=true") { + t.Fatalf("expected all fields in dry-run output, got: %s", output) + } + if !strings.Contains(output, `"user_id_type": "open_id"`) { + t.Fatalf("expected user_id_type param in dry-run output, got: %s", output) + } +} + +func TestPatchDryRun_KeyResult_WithUserIDType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "456", + "--score", "0.7", + "--user-id-type", "user_id", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, `"user_id_type": "user_id"`) { + t.Fatalf("expected user_id_type=user_id in dry-run output, got: %s", output) + } +} + +// --- Execute tests --- + +func TestPatchExecute_Objective_Success(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + 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 + } + // Check content is present and is a ContentBlock structure + content, ok := data["content"].(map[string]interface{}) + if !ok { + return false + } + blocks, ok := content["blocks"].([]interface{}) + if !ok || len(blocks) == 0 { + return false + } + // Check score + score, ok := data["score"].(float64) + if !ok || score != 0.5 { + return false + } + // Check notes + notes, ok := data["notes"].(map[string]interface{}) + if !ok { + return false + } + notesBlocks, ok := notes["blocks"].([]interface{}) + if !ok || len(notesBlocks) == 0 { + return false + } + // Check deadline + deadline, ok := data["deadline"].(string) + if !ok || deadline != "1735776000000" { + return false + } + return true + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", validSemiPlainJSON, + "--notes", validSemiPlainJSON, + "--score", "0.5", + "--deadline", "1735776000000", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, `"level": "objective"`) { + t.Fatalf("expected objective level in output, got: %s", output) + } + if !strings.Contains(output, `"target_id": "123"`) { + t.Fatalf("expected target_id in output, got: %s", output) + } + if !strings.Contains(output, `"content": true`) || + !strings.Contains(output, `"notes": true`) || + !strings.Contains(output, `"score": true`) || + !strings.Contains(output, `"deadline": true`) { + t.Fatalf("expected all field patches in output, got: %s", output) + } +} + +func TestPatchExecute_KeyResult_Success(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/key_results/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 + } + // Check content is present + content, ok := data["content"].(map[string]interface{}) + if !ok { + return false + } + blocks, ok := content["blocks"].([]interface{}) + if !ok || len(blocks) == 0 { + return false + } + // Check score + score, ok := data["score"].(float64) + if !ok || score != 1.0 { + return false + } + // Notes should NOT be present for key-result + if _, hasNotes := data["notes"]; hasNotes { + return false + } + return true + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "456", + "--style", "richtext", + "--content", validContentBlockJSON, + "--score", "1.0", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, `"level": "key-result"`) { + t.Fatalf("expected key-result level in output, got: %s", output) + } + if !strings.Contains(output, `"target_id": "456"`) { + t.Fatalf("expected target_id in output, got: %s", output) + } + if !strings.Contains(output, `"content": true`) || + !strings.Contains(output, `"score": true`) { + t.Fatalf("expected field patches in output, got: %s", output) + } + if strings.Contains(output, `"notes": true`) { + t.Fatalf("unexpected notes patch in key-result output, got: %s", output) + } +} + +func TestPatchExecute_OnlyScore(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + 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 + } + // Only score should be present + if _, hasContent := data["content"]; hasContent { + return false + } + if _, hasNotes := data["notes"]; hasNotes { + return false + } + if _, hasDeadline := data["deadline"]; hasDeadline { + return false + } + score, ok := data["score"].(float64) + return ok && score == 0.3 + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "0.3", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, `"score": true`) { + t.Fatalf("expected score patch in output, got: %s", output) + } + if strings.Contains(output, `"content": true`) || + strings.Contains(output, `"notes": true`) || + strings.Contains(output, `"deadline": true`) { + t.Fatalf("unexpected field patches in output, got: %s", output) + } +} + +func TestPatchExecute_APIError(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 9999, + "msg": "patch error", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "0.5", + }) + if err == nil { + t.Fatal("expected error for 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 TestPatchExecute_WithUserIDType(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/key_results/789", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "789", + "--score", "0.8", + "--user-id-type", "union_id", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- parsePatchParams tests --- + +func TestParsePatchParams_ScoreRounding(t *testing.T) { + t.Parallel() + // Valid score with one decimal place is accepted (score 0.3) + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + 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 + } + score, ok := data["score"].(float64) + // 0.33 should round to 0.3 + return ok && score == 0.3 + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "0.3", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/okr/okr_progress_create.go b/shortcuts/okr/okr_progress_create.go index 62d976a2..3a56d5d9 100644 --- a/shortcuts/okr/okr_progress_create.go +++ b/shortcuts/okr/okr_progress_create.go @@ -10,6 +10,7 @@ import ( "io" "math" "strconv" + "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" @@ -35,12 +36,37 @@ type createProgressRecordParams struct { // parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters. func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) { + style := runtime.Str("style") content := runtime.Str("content") - var cb ContentBlock - if err := json.Unmarshal([]byte(content), &cb); err != nil { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + var contentV1 *ContentBlockV1 + + if style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(content), &sp); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content") + } + // Validate mention IDs are non-empty + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content") + } + } + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content") + } + // Build ContentBlock from semi-plain content (text + mentions) + contentV1 = sp.ToContentBlock().ToV1() + } else { + // richtext mode + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + } + contentV1 = cb.ToV1() } - contentV1 := cb.ToV1() targetType := runtime.Str("target-type") targetTypeVal := targetTypeAllowed[targetType] @@ -92,7 +118,7 @@ var OKRCreateProgressRecord = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ - {Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}}, {Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true}, {Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}}, {Name: "progress-percent", Desc: "progress percentage"}, @@ -100,6 +126,7 @@ var OKRCreateProgressRecord = common.Shortcut{ {Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"}, {Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"}, {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + {Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { content := runtime.Str("content") @@ -109,10 +136,36 @@ var OKRCreateProgressRecord = common.Shortcut{ if err := common.RejectDangerousCharsTyped("--content", content); err != nil { return err } - // Validate content is valid JSON and can be parsed as ContentBlock - var cb ContentBlock - if err := json.Unmarshal([]byte(content), &cb); err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + + style := runtime.Str("style") + if style != "simple" && style != "richtext" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style") + } + + // Validate content based on style + if style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(content), &sp); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content") + } + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content") + } + } + // If user provided docs or images in simple mode, warn that they are ignored + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content") + } + } else { + // richtext mode + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + } } targetID := runtime.Str("target-id") @@ -213,21 +266,43 @@ var OKRCreateProgressRecord = common.Shortcut{ return err } - resp := record.ToResp() - result := map[string]interface{}{ - "progress": resp, - } + style := runtime.Str("style") + var result map[string]interface{} + if style == "simple" { + resp := record.ToSimple() + result = map[string]interface{}{ + "progress": resp, + "style": style, + } - runtime.OutFormat(result, nil, func(w io.Writer) { - fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID) - fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) - if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { - fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", resp.Content.Text) + } + }) + } else { + resp := record.ToResp() + result = map[string]interface{}{ + "progress": resp, + "style": style, } - if resp.Content != nil { - fmt.Fprintf(w, " Content: %s\n", *resp.Content) - } - }) + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", *resp.Content) + } + }) + } return nil }, } diff --git a/shortcuts/okr/okr_progress_create_test.go b/shortcuts/okr/okr_progress_create_test.go index 87b26661..ba5c1999 100644 --- a/shortcuts/okr/okr_progress_create_test.go +++ b/shortcuts/okr/okr_progress_create_test.go @@ -5,11 +5,13 @@ package okr import ( "bytes" + "errors" "strings" "testing" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" @@ -38,6 +40,7 @@ func runProgressCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.B } const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}` +const validSemiPlainJSON = `{"text":"test content","mention":["ou_123"]}` // --- Validate tests --- @@ -60,6 +63,7 @@ func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", "not-json", + "--style", "richtext", "--target-id", "123", "--target-type", "objective", }) @@ -77,6 +81,7 @@ func TestProgressCreateValidate_MissingTargetID(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-type", "objective", }) if err == nil { @@ -90,6 +95,7 @@ func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "abc", "--target-type", "objective", }) @@ -107,6 +113,7 @@ func TestProgressCreateValidate_InvalidTargetType(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "invalid", }) @@ -124,6 +131,7 @@ func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}", + "--style", "richtext", "--target-id", "123", "--target-type", "objective", }) @@ -138,6 +146,7 @@ func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--user-id-type", "invalid", @@ -153,6 +162,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T) err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--progress-percent", "999999999999", @@ -171,6 +181,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T) err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--progress-percent", "abc", @@ -189,6 +200,7 @@ func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--progress-status", "invalid_status", @@ -219,6 +231,7 @@ func TestProgressCreateValidate_Valid(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", }) @@ -235,6 +248,7 @@ func TestProgressCreateDryRun(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--dry-run", @@ -264,6 +278,7 @@ func TestProgressCreateDryRun_WithProgressRate(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--progress-percent", "75", @@ -299,6 +314,7 @@ func TestProgressCreateExecute_Success(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "456", "--target-type", "key_result", }) @@ -330,6 +346,7 @@ func TestProgressCreateExecute_APIError(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "789", "--target-type", "objective", }) @@ -337,3 +354,200 @@ func TestProgressCreateExecute_APIError(t *testing.T) { t.Fatal("expected error for API failure") } } + +// --- Simple mode tests --- + +func TestProgressCreateExecute_SimpleMode_DefaultStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/progress_records/", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "300", + "modify_time": "1735776000000", + }, + }, + }) + // Use default style (simple) without specifying --style + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validSemiPlainJSON, + "--target-id", "123", + "--target-type", "objective", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + pr, _ := data["progress"].(map[string]interface{}) + if pr == nil { + t.Fatal("expected progress in output") + } + if pr["progress_id"] != "300" { + t.Fatalf("progress_id = %v, want 300", pr["progress_id"]) + } +} + +func TestProgressCreateExecute_SimpleMode_ExplicitStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/progress_records/", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "400", + "modify_time": "1735776000000", + }, + }, + }) + // Explicitly specify --style simple with mentions + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", `{"text":"simple progress with mention","mention":["ou_abc","ou_def"]}`, + "--style", "simple", + "--target-id", "456", + "--target-type", "key_result", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + pr, _ := data["progress"].(map[string]interface{}) + if pr == nil { + t.Fatal("expected progress in output") + } + if pr["progress_id"] != "400" { + t.Fatalf("progress_id = %v, want 400", pr["progress_id"]) + } +} + +func TestProgressCreateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", `{"text":"missing closing brace`, + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for invalid semi-plain JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateValidate_SimpleMode_EmptyText(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", `{"text":" ","mention":[]}`, + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for empty text in simple mode") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "--content text is required and cannot be empty") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateValidate_SimpleMode_DocsImagesNotSupported(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", `{"text":"has docs","mention":[],"docs":[{"title":"doc","url":"https://example.com"}]}`, + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for docs in simple mode") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateDryRun_SimpleMode(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validSemiPlainJSON, + "--target-id", "123", + "--target-type", "objective", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "/open-apis/okr/v1/progress_records/") { + t.Fatalf("dry-run output should contain API path, got: %s", output) + } + if !strings.Contains(output, "POST") { + t.Fatalf("dry-run output should contain POST method, got: %s", output) + } +} diff --git a/shortcuts/okr/okr_progress_get.go b/shortcuts/okr/okr_progress_get.go index 2878f0ef..e98e0944 100644 --- a/shortcuts/okr/okr_progress_get.go +++ b/shortcuts/okr/okr_progress_get.go @@ -26,6 +26,7 @@ var OKRGetProgressRecord = common.Shortcut{ Flags: []common.Flag{ {Name: "progress-id", Desc: "progress ID (int64)", Required: true}, {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + {Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { progressID := runtime.Str("progress-id") @@ -39,6 +40,10 @@ var OKRGetProgressRecord = common.Shortcut{ 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") } + style := runtime.Str("style") + if style != "simple" && style != "richtext" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style") + } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -55,6 +60,7 @@ var OKRGetProgressRecord = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { progressID := runtime.Str("progress-id") userIDType := runtime.Str("user-id-type") + style := runtime.Str("style") queryParams := map[string]interface{}{"user_id_type": userIDType} @@ -69,21 +75,45 @@ var OKRGetProgressRecord = common.Shortcut{ return err } - resp := record.ToResp() - result := map[string]interface{}{ - "progress": resp, - } + var result map[string]interface{} + if style == "simple" { + resp := record.ToSimple() + result = map[string]interface{}{ + "progress": resp, + "style": style, + } - runtime.OutFormat(result, nil, func(w io.Writer) { - fmt.Fprintf(w, "Progress [%s]\n", resp.ID) - fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) - if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { - fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", resp.Content.Text) + if len(resp.Content.Mention) > 0 { + fmt.Fprintf(w, " Mentions: %v\n", resp.Content.Mention) + } + } + }) + } else { + resp := record.ToResp() + result = map[string]interface{}{ + "progress": resp, + "style": style, } - if resp.Content != nil { - fmt.Fprintf(w, " Content: %s\n", *resp.Content) - } - }) + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", *resp.Content) + } + }) + } return nil }, } diff --git a/shortcuts/okr/okr_progress_update.go b/shortcuts/okr/okr_progress_update.go index c492ec75..52ae3b86 100644 --- a/shortcuts/okr/okr_progress_update.go +++ b/shortcuts/okr/okr_progress_update.go @@ -10,6 +10,7 @@ import ( "io" "math" "strconv" + "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" @@ -25,12 +26,35 @@ type updateProgressRecordParams struct { // parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters. func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) { + style := runtime.Str("style") content := runtime.Str("content") - var cb ContentBlock - if err := json.Unmarshal([]byte(content), &cb); err != nil { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + var contentV1 *ContentBlockV1 + + if style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(content), &sp); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content") + } + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content") + } + } + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content") + } + contentV1 = sp.ToContentBlock().ToV1() + } else { + // richtext mode + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + } + contentV1 = cb.ToV1() } - contentV1 := cb.ToV1() var progressRate *ProgressRateV1 if v := runtime.Str("progress-percent"); v != "" { @@ -67,10 +91,11 @@ var OKRUpdateProgressRecord = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "progress-id", Desc: "progress ID (int64)", Required: true}, - {Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}}, {Name: "progress-percent", Desc: "progress percentage"}, {Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}}, {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + {Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { progressID := runtime.Str("progress-id") @@ -88,9 +113,35 @@ var OKRUpdateProgressRecord = common.Shortcut{ if err := common.RejectDangerousCharsTyped("--content", content); err != nil { return err } - var cb ContentBlock - if err := json.Unmarshal([]byte(content), &cb); err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + + style := runtime.Str("style") + if style != "simple" && style != "richtext" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style") + } + + // Validate content based on style + if style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(content), &sp); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content") + } + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content") + } + } + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content") + } + } else { + // richtext mode + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + } } if v := runtime.Str("progress-percent"); v != "" { @@ -158,21 +209,43 @@ var OKRUpdateProgressRecord = common.Shortcut{ return err } - resp := record.ToResp() - result := map[string]interface{}{ - "progress": resp, - } + style := runtime.Str("style") + var result map[string]interface{} + if style == "simple" { + resp := record.ToSimple() + result = map[string]interface{}{ + "progress": resp, + "style": style, + } - runtime.OutFormat(result, nil, func(w io.Writer) { - fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID) - fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) - if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { - fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent) + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", resp.Content.Text) + } + }) + } else { + resp := record.ToResp() + result = map[string]interface{}{ + "progress": resp, + "style": style, } - if resp.Content != nil { - fmt.Fprintf(w, " Content: %s\n", *resp.Content) - } - }) + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", *resp.Content) + } + }) + } return nil }, } diff --git a/shortcuts/okr/okr_progress_update_test.go b/shortcuts/okr/okr_progress_update_test.go index 6d56d4dd..b3f2cc46 100644 --- a/shortcuts/okr/okr_progress_update_test.go +++ b/shortcuts/okr/okr_progress_update_test.go @@ -5,11 +5,13 @@ package okr import ( "bytes" + "errors" "strings" "testing" "github.com/spf13/cobra" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" @@ -45,6 +47,7 @@ func TestProgressUpdateValidate_MissingProgressID(t *testing.T) { err := runProgressUpdateShortcut(t, f, stdout, []string{ "+progress-update", "--content", validContentBlockJSON, + "--style", "richtext", }) if err == nil { t.Fatal("expected error for missing --progress-id") @@ -58,6 +61,7 @@ func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) { "+progress-update", "--progress-id", "abc", "--content", validContentBlockJSON, + "--style", "richtext", }) if err == nil { t.Fatal("expected error for invalid --progress-id") @@ -86,6 +90,7 @@ func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) { "+progress-update", "--progress-id", "123", "--content", "not-json", + "--style", "richtext", }) if err == nil { t.Fatal("expected error for invalid --content JSON") @@ -102,6 +107,7 @@ func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) { "+progress-update", "--progress-id", "123", "--content", validContentBlockJSON, + "--style", "richtext", "--user-id-type", "invalid", }) if err == nil { @@ -116,6 +122,7 @@ func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T) "+progress-update", "--progress-id", "123", "--content", validContentBlockJSON, + "--style", "richtext", "--progress-percent", "-999999999999", }) if err == nil { @@ -133,6 +140,7 @@ func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) { "+progress-update", "--progress-id", "123", "--content", validContentBlockJSON, + "--style", "richtext", "--progress-status", "invalid_status", }) if err == nil { @@ -162,6 +170,7 @@ func TestProgressUpdateValidate_Valid(t *testing.T) { "+progress-update", "--progress-id", "123", "--content", validContentBlockJSON, + "--style", "richtext", }) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -177,6 +186,7 @@ func TestProgressUpdateDryRun(t *testing.T) { "+progress-update", "--progress-id", "456", "--content", validContentBlockJSON, + "--style", "richtext", "--dry-run", }) if err != nil { @@ -201,6 +211,7 @@ func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) { "+progress-update", "--progress-id", "456", "--content", validContentBlockJSON, + "--style", "richtext", "--progress-percent", "50", "--progress-status", "overdue", "--dry-run", @@ -235,6 +246,7 @@ func TestProgressUpdateExecute_Success(t *testing.T) { "+progress-update", "--progress-id", "789", "--content", validContentBlockJSON, + "--style", "richtext", }) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -265,8 +277,202 @@ func TestProgressUpdateExecute_APIError(t *testing.T) { "+progress-update", "--progress-id", "999", "--content", validContentBlockJSON, + "--style", "richtext", }) if err == nil { t.Fatal("expected error for API failure") } } + +// --- Simple mode tests --- + +func TestProgressUpdateExecute_SimpleMode_DefaultStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/okr/v1/progress_records/500", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "500", + "modify_time": "1735776000000", + }, + }, + }) + // Use default style (simple) without specifying --style + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "500", + "--content", validSemiPlainJSON, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + pr, _ := data["progress"].(map[string]interface{}) + if pr == nil { + t.Fatal("expected progress in output") + } + if pr["progress_id"] != "500" { + t.Fatalf("progress_id = %v, want 500", pr["progress_id"]) + } +} + +func TestProgressUpdateExecute_SimpleMode_ExplicitStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/okr/v1/progress_records/600", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "600", + "modify_time": "1735776000000", + }, + }, + }) + // Explicitly specify --style simple with mentions and progress rate + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "600", + "--content", `{"text":"updated progress","mention":["ou_abc"]}`, + "--style", "simple", + "--progress-percent", "80", + "--progress-status", "normal", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + pr, _ := data["progress"].(map[string]interface{}) + if pr == nil { + t.Fatal("expected progress in output") + } + if pr["progress_id"] != "600" { + t.Fatalf("progress_id = %v, want 600", pr["progress_id"]) + } +} + +func TestProgressUpdateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + "--content", `{"text":"invalid json`, + }) + if err == nil { + t.Fatal("expected error for invalid semi-plain JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressUpdateValidate_SimpleMode_EmptyMention(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + "--content", `{"text":"has empty mention","mention":["ou_abc",""]}`, + }) + if err == nil { + t.Fatal("expected error for empty mention in simple mode") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "--content mention[1] cannot be empty") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressUpdateValidate_SimpleMode_ImagesNotSupported(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + "--content", `{"text":"has images","mention":[],"images":["img_token"]}`, + }) + if err == nil { + t.Fatal("expected error for images in simple mode") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressUpdateDryRun_SimpleMode(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "700", + "--content", validSemiPlainJSON, + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "/open-apis/okr/v1/progress_records/700") { + t.Fatalf("dry-run output should contain API path, got: %s", output) + } + if !strings.Contains(output, "PUT") { + t.Fatalf("dry-run output should contain PUT method, got: %s", output) + } +} diff --git a/shortcuts/okr/shortcuts.go b/shortcuts/okr/shortcuts.go index eb476c56..5b371e69 100644 --- a/shortcuts/okr/shortcuts.go +++ b/shortcuts/okr/shortcuts.go @@ -22,5 +22,6 @@ func Shortcuts() []common.Shortcut { OKRReorder, OKRWeight, OKRIndicatorUpdate, + OKRPatch, } } diff --git a/skills/lark-okr/SKILL.md b/skills/lark-okr/SKILL.md index 8d02fd4d..4c59768e 100644 --- a/skills/lark-okr/SKILL.md +++ b/skills/lark-okr/SKILL.md @@ -31,12 +31,13 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr + [flags]`) | [`+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 的指标当前值 | +| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值(简单场景推荐)。更复杂的指标操作见 [量化指标管理](references/lark-okr-indicators.md) | +| [`+patch`](references/lark-okr-patch.md) | 部分更新 Objective 或 KR(content、notes、score、deadline) | ## 格式说明 - [`OKR 业务实体`](references/lark-okr-entities.md) 获取 OKR 实体结构,定义和关系,帮助你更好的使用 OKR 功能 -- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note 字段使用的富文本格式说明 +- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note 字段使用的富文本格式说明,以及简化的半纯文本(SemiPlainContent)格式的进一步说明。 - **强烈建议** 在操作 OKR 前,阅读[`OKR 业务实体`](references/lark-okr-entities.md)以了解基础概念 ## API Resources @@ -46,6 +47,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr + [flags]`) - `delete` — 删除对齐关系 - `get` — 获取对齐关系 +> **操作指南:** [OKR 对齐关系管理](references/lark-okr-alignments.md) 包含 list/create/delete 完整工作流 + ### categories - `list` — 批量获取分类 @@ -71,6 +74,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr + [flags]`) - `patch` — 更新量化指标 +> **操作指南:** [OKR 量化指标管理](references/lark-okr-indicators.md) 包含目标/KR 指标查询和 patch 更新完整工作流 + ### key_results - `delete` — 删除关键结果 @@ -81,6 +86,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr + [flags]`) - `list` — 获取关键结果的量化指标 +> **操作指南:** [OKR 量化指标管理](references/lark-okr-indicators.md) + ### objectives - `delete` — 删除目标 diff --git a/skills/lark-okr/references/lark-okr-alignments.md b/skills/lark-okr/references/lark-okr-alignments.md new file mode 100644 index 00000000..bcdc3728 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-alignments.md @@ -0,0 +1,180 @@ +# OKR 对齐关系管理 + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +管理 OKR 目标之间的对齐关系,包括查询、创建和删除对齐。 + +## 对齐关系说明 + +OKR 对齐关系表示两个目标之间的关联: +- **对齐(aligning)**:目标 A 对齐到目标 B,表示 A 的完成有助于 B 的完成 +- **被对齐(aligned)**:目标 B 被目标 A 对齐 + +每个对齐关系有唯一的 `alignment_id`,用于删除操作。 + +--- + +## 一、查询对齐关系 + +### 命令 + +```bash +lark-cli okr objective.alignments list --objective-id "<目标ID>" [flags] +``` + +### 常用示例 + +```bash +# 获取目标的所有对齐关系(同时包含对齐和被对齐) +lark-cli okr objective.alignments list \ + --objective-id "7652569715131075772" + +# 只查询该目标主动对齐他人的关系 +lark-cli okr objective.alignments list \ + --objective-id "7652569715131075772" \ + --align-type "aligning" + +# 只查询他人对齐该目标的关系 +lark-cli okr objective.alignments list \ + --objective-id "7652569715131075772" \ + --align-type "aligned" + +# 自动分页获取全部数据 +lark-cli okr objective.alignments list \ + --objective-id "7652569715131075772" \ + --page-all +``` + +### 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|----------------------|----|----------------|--------------------------------------------------------------------| +| `--objective-id` | 是 | — | 目标 ID | +| `--align-type` | 否 | — | 对齐类型:`aligning`(该目标对齐他人)\| `aligned`(他人对齐该目标)。留空返回全部。 | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--page-size` | 否 | `10` | 分页大小,最大 100 | +| `--page-all` | 否 | — | 自动分页获取全部数据 | + +### 返回字段说明 + +- `items[].id`:对齐关系 ID(删除时需要) +- `items[].from_entity_id`:发起对齐的目标 ID +- `items[].to_entity_id`:被对齐的目标 ID +- `items[].from_owner` / `to_owner`:双方所有者信息 + +--- + +## 二、创建对齐关系 + +### 命令 + +```bash +lark-cli okr objective.alignments create --objective-id "<发起对齐的目标ID>" --data '' +``` + +### 常用示例 + +```bash +# 创建对齐关系:目标 7652569715131075772 对齐到目标 7652569715131075773 +lark-cli okr objective.alignments create \ + --objective-id "7652569715131075772" \ + --data '{"to_entity_id":"7652569715131075773","to_entity_type":2}' + +# 从文件读取请求体 +lark-cli okr objective.alignments create \ + --objective-id "7652569715131075772" \ + --data @alignment.json +``` + +### 参数 + +| 参数 | 必填 | 说明 | +|------------------|----|--------------------------------------------------------------------| +| `--objective-id` | 是 | 发起对齐的目标 ID("我"的目标) | +| `--data` | 是 | JSON 请求体,格式见下方。支持 `@文件路径` 从文件读取。 | + +### 请求体格式 + +```json +{ + "to_entity_id": "7652569715131075773", // 被对齐的目标 ID + "to_entity_type": 2 // 固定值 2,表示目标类型 +} +``` + +### 对齐规则 + +- **禁止自对齐**:不能自己对齐自己 +- **周期时间重叠**:两个目标所在周期的时间范围必须有重叠 +- **权限要求**:需要对发起对齐的目标有编辑权限 + +### 返回 + +成功后返回 `alignment_id`,保存好以便后续删除。 + +--- + +## 三、删除对齐关系 + +### 命令 + +```bash +lark-cli okr alignments delete --alignment-id "<对齐关系ID>" +``` + +### 常用示例 + +```bash +# 删除指定的对齐关系 +lark-cli okr alignments delete \ + --alignment-id "7652569715131075780" +``` + +### 参数 + +| 参数 | 必填 | 说明 | +|------------------|----|--------------------------------------| +| `--alignment-id` | 是 | 对齐关系 ID(从 list 或 create 返回) | + +### 注意事项 + +- 删除操作不可逆,请谨慎操作 +- 需要对关联的目标有编辑权限 + +--- + +## 完整工作流示例 + +### 场景:将目标 A 对齐到目标 B + +1. **查询现有对齐关系**(确认是否已存在) + ```bash + lark-cli okr objective.alignments list \ + --objective-id "目标A的ID" \ + --align-type "aligning" + ``` + +2. **创建对齐关系** + ```bash + lark-cli okr objective.alignments create \ + --objective-id "目标A的ID" \ + --data '{"to_entity_id":"目标B的ID","to_entity_type":2}' + ``` + +3. **验证对齐结果** + ```bash + lark-cli okr objective.alignments list \ + --objective-id "目标A的ID" \ + --align-type "aligning" + ``` + +4. **(如需)删除对齐关系** + ```bash + lark-cli okr alignments delete \ + --alignment-id "从步骤1返回的alignment_id" + ``` + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-okr/references/lark-okr-contentblock.md b/skills/lark-okr/references/lark-okr-contentblock.md index 9c60bccd..310a42eb 100644 --- a/skills/lark-okr/references/lark-okr-contentblock.md +++ b/skills/lark-okr/references/lark-okr-contentblock.md @@ -2,6 +2,17 @@ OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock` 富文本格式。本文档描述其结构和使用方式。 +## 两种输入输出风格 + +OKR shortcuts 支持 `--style` 标志控制 content/notes 字段的输入输出格式: + +| `--style` 值 | 说明 | 适用场景 | +|--------------|--------------------------------------------------------------------|--------------------------| +| `simple`(默认) | 半纯文本格式 `SemiPlainContent`,简化的 JSON 结构,仅包含 text、mention、docs、images | 大多数场景,简单易用 | +| `richtext` | 原始 `ContentBlock` 富文本格式,完整的块结构和样式信息 | 需要精确控制@提及用户位置、包含图片/文档链接时 | + +**重要**:输入时严格根据 `--style` 值验证格式,不会自动检测。输出时读操作(如 `+cycle-detail`、`+progress-get`)根据 `--style` 返回对应格式。 + ## ContentBlock 结构概览 ```json @@ -215,9 +226,66 @@ OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock` |-------|----------|--------| | `url` | `string` | 链接 URL | +## SemiPlainContent 半纯文本格式 + +`SemiPlainContent` 是 `ContentBlock` 的简化、有损表示形式,适用于大多数不需要复杂格式的场景。 + +### 结构 + +```json +{ + "text": "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ", + "mention": ["ou_zhangsan", "ou_lisi"], + "docs": [ + { + "title": "产品需求文档", + "url": "https://larkoffice.com/docx/xxx" + } + ], + "images": [ + "https://example.com/image.png" + ] +} +``` + +### 类型定义 + +| 字段 | 类型 | 说明 | +|-----------|------------------|-----------------------------------------------------------------------------------------------------------| +| `text` | `string` | 纯文本内容(必填,不能为空)。**输出时**包含 ` @{userID} ` 占位符以保留提及的位置上下文;**输入时** `@{...}` 占位符会被自动 strip 掉,只识别 `mention` 字段内容 | +| `mention` | `string[]` | 用户 ID 列表(可选),与 text 中的 `@{userID}` 占位符一一对应,输入时按顺序转换为 mention 元素**置于文本末尾** | +| `docs` | `SemiPlainDoc[]` | 文档列表(仅输出时包含,输入时 simple 风格不支持) | +| `images` | `string[]` | 图片 URL 列表(仅输出时包含,输入时 simple 风格不支持) | + +### SemiPlainDoc + +| 字段 | 类型 | 说明 | +|---------|----------|--------| +| `title` | `string` | 文档标题 | +| `url` | `string` | 文档 URL | + +### 双向转换说明 + +- **ContentBlock → SemiPlainContent**(输出时):提取纯文本、提及用户、文档链接和图片 URL,丢弃格式信息(粗体、列表、颜色等)。**提及的位置信息通过 ` @{userID} ` 占位符保留在 text 中**,同时 userID 也会被收集到 mention 数组中 +- **SemiPlainContent → ContentBlock**(输入时):自动 strip 掉 text 中的 `@{...}` 占位符,然后将 text 和 mention 合并为单个段落,mention 按顺序附加在文本末尾。docs 和 images 在输入时被忽略(simple 风格不支持) + ## 使用示例 -### 示例 1:简单文本段落 +### 示例 0:--style simple 半纯文本格式 + +```json +{ + "text": "提升用户满意度", + "mention": ["ou_123"] +} +``` + +使用方式: +```bash +lark-cli okr +patch --level objective --style simple --target-id 123 --content '{"text":"提升用户满意度","mention":["ou_123"]}' +``` + +### 示例 1:简单文本段落(richtext 风格) ```json { diff --git a/skills/lark-okr/references/lark-okr-cycle-detail.md b/skills/lark-okr/references/lark-okr-cycle-detail.md index 532bbe5c..9992f002 100644 --- a/skills/lark-okr/references/lark-okr-cycle-detail.md +++ b/skills/lark-okr/references/lark-okr-cycle-detail.md @@ -7,20 +7,24 @@ ## 推荐命令 ```bash -# 列出指定周期的目标和关键结果 +# 列出指定周期的目标和关键结果(默认 simple 风格,半纯文本格式,推荐使用,更简洁) lark-cli okr +cycle-detail --cycle-id 1234567890123456789 +# 列出指定周期的目标和关键结果(richtext 风格,原始 ContentBlock JSON) +lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --style richtext + # 预览 API 调用而不实际执行 lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run ``` ## 参数 -| 参数 | 必填 | 默认值 | 说明 | -|--------------|----|--------|-----------------------------------------| -| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 | -| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | -| `--format` | 否 | `json` | 输出格式。 | +| 参数 | 必填 | 默认值 | 说明 | +|--------------|----|----------|-----------------------------------------------------------------------------------------------------------------------------| +| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 | +| `--style` | 否 | `simple` | 输出风格:`simple`(半纯文本格式,不涉及字体/颜色等信息时推荐使用) \| `richtext`(原始 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | ## 工作流程 @@ -75,8 +79,11 @@ lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run } ``` -其中,content 和 notes 字段是 JSON 字符串,为 OKR ContentBlock -富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。 +其中,content 和 notes 字段格式由 `--style` 控制: +- `--style simple`(默认):`SemiPlainContent` 对象,包含 `text`、`mention`、`docs` 字段 +- `--style richtext`:JSON 字符串,为 OKR ContentBlock 富文本格式 + +请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解两种格式的详细信息。 ## 参考 diff --git a/skills/lark-okr/references/lark-okr-cycle-list.md b/skills/lark-okr/references/lark-okr-cycle-list.md index ac7a1223..11632573 100644 --- a/skills/lark-okr/references/lark-okr-cycle-list.md +++ b/skills/lark-okr/references/lark-okr-cycle-list.md @@ -46,20 +46,20 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run "cycles": [ { "id": "1234567890123456789", - "create_time": "2025-01-01 00:00:00", - "update_time": "2025-01-01 00:00:00", - "tenant_cycle_id": "789", - "owner": { - "owner_type": "user", - "user_id": "ou_xxx" - }, "start_time": "2025-01-01 00:00:00", "end_time": "2025-06-30 00:00:00", - "cycle_status": "normal", - "score": 0 + "cycle_status": "normal" } ], - "total": 1 + "total": 1, + "current_active_cycles": [ + { + "id": "1234567890123456789", + "start_time": "2025-01-01 00:00:00", + "end_time": "2025-06-30 00:00:00", + "cycle_status": "normal" + } + ] } ``` @@ -67,11 +67,14 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run - `id` 是这个周期的 ID,你通常需要用它在之后使用 `okr +cycle-detail` 获取 OKR 内容详情 - `start_time` `end_time` 是周期的起止时间,总是从某个月1日开始,直到此月或之后某月的最后一日结束。 - - 在 OKR 系统中,我们只关注这个时间的年月部分,如 “2025-01-01开始,2025-06-30结束” 的周期被称作 “2025 年 1-6 月” 周期,而 - “2025-01-01开始,2025-01-31结束” 的周期被称作 “2025 年 1 月”周期。 - - 如果一个周期从某年1月1日开始,某年12月31日结束,则它是这一年的年度周期,如 “2025-01-01开始,2025-12-31结束” 的周期就是 - “2025 年” 的年度周期 + - 在 OKR 系统中,我们只关注这个时间的年月部分,如 "2025-01-01开始,2025-06-30结束" 的周期被称作 "2025 年 1-6 月" 周期,而 + "2025-01-01开始,2025-01-31结束" 的周期被称作 "2025 年 1 月"周期。 + - 如果一个周期从某年1月1日开始,某年12月31日结束,则它是这一年的年度周期,如 "2025-01-01开始,2025-12-31结束" 的周期就是 + "2025 年" 的年度周期 - `cycle_status` 为周期状态值,参见下文。 +- `current_active_cycles` 是当前生效的周期列表,不过根据用户的周期设置,可能会出现为空的场景。 + +如果需要获取周期的创建时间/总分等信息,可以通过原生 API `okr cycles list` 获取。 ### 周期状态值 diff --git a/skills/lark-okr/references/lark-okr-indicators.md b/skills/lark-okr/references/lark-okr-indicators.md new file mode 100644 index 00000000..b16ab6bf --- /dev/null +++ b/skills/lark-okr/references/lark-okr-indicators.md @@ -0,0 +1,223 @@ +# OKR 量化指标管理 + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +管理 OKR 目标(Objective)和关键结果(Key Result)的量化指标,包括查询和更新指标。 + +> **快速更新当前值:** 如果只需要更新指标的当前值,推荐使用 shortcut [`okr +indicator-update`](lark-okr-indicator-update.md),无需手动查询指标 ID。 +> +> 本指南中的原生 API 适用于需要修改指标其他字段(如 `unit`、`target_value`、`status_calculate_type` 等)的场景。 + +--- + +## 指标字段说明 + +| 字段 | 类型 | 说明 | +|-----------------------------|------|--------------------------------------------------------------------| +| `id` | string | 指标 ID(更新时需要) | +| `entity_id` / `entity_type` | string/int | 所属实体 ID 和类型(2=目标,3=关键结果) | +| `current_value` | number | 当前值 | +| `target_value` | number | 目标值 | +| `start_value` | number | 起始值 | +| `indicator_status` | int | 状态:-1=未定义,0=正常,1=有风险,2=已延期 | +| `status_calculate_type` | int | 状态计算方式:0=手动更新,1=基于进度和当前时间自动更新,2=基于风险最高的 KR 状态更新 | +| `current_value_calculate_type` | int | 当前值计算方式:0=手动更新,1=基于 KR 进度自动更新(目标),2=基于拆解 KR 进度更新(KR) | +| `unit` | object | 单位,包含 `unit_type`(0=公共,1=自定义)和 `unit_value`(如 PERCENT、YUAN 等) | +| `owner` | object | 所有者 | + +--- + +## 一、查询目标的量化指标 + +### 命令 + +```bash +lark-cli okr objective.indicators list --objective-id "<目标ID>" [flags] +``` + +### 常用示例 + +```bash +# 获取目标的量化指标 +lark-cli okr objective.indicators list \ + --objective-id 7652569715131075772 + +# 指定用户 ID 类型 +lark-cli okr objective.indicators list \ + --objective-id 7652569715131075772 \ + --user-id-type "user_id" +``` + +### 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|----------------------|----|----------------|-----------------------------------------------------| +| `--objective-id` | 是 | — | 目标 ID | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--department-id-type` | 否 | `open_department_id` | 部门 ID 类型:`open_department_id` \| `department_id` | + +### 返回 + +返回 `indicator` 字段,包含该目标的量化指标详情。 + +--- + +## 二、查询关键结果的量化指标 + +### 命令 + +```bash +lark-cli okr key_result.indicators list --key-result-id "<关键结果ID>" [flags] +``` + +### 常用示例 + +```bash +# 获取关键结果的量化指标 +lark-cli okr key_result.indicators list \ + --key-result-id "7652569715131075780" +``` + +### 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|----------------------|----|----------------|-----------------------------------------------------| +| `--key-result-id` | 是 | — | 关键结果 ID | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--department-id-type` | 否 | `open_department_id` | 部门 ID 类型:`open_department_id` \| `department_id` | + +### 返回 + +返回 `indicator` 字段,包含该关键结果的量化指标详情。 + +--- + +## 三、更新量化指标 + +### 命令 + +```bash +lark-cli okr indicators patch --indicator-id "<指标ID>" --data '' +``` + +### 常用示例 + +```bash +# 更新指标的当前值(手动更新方式) +lark-cli okr indicators patch \ + --indicator-id "ind-123" \ + --data '{"current_value": 75.5, "current_value_calculate_type": 0}' + +# 更新指标状态为"有风险"(需 status_calculate_type=0) +lark-cli okr indicators patch \ + --indicator-id "ind-123" \ + --data '{"indicator_status": 1, "status_calculate_type": 0}' + +# 更新关键结果指标的目标值和单位 +lark-cli okr indicators patch \ + --indicator-id "ind-456" \ + --data '{ + "target_value": 100, + "unit": {"unit_type": 0, "unit_value": "PERCENT"} + }' + +# 从文件读取请求体 +lark-cli okr indicators patch \ + --indicator-id "ind-123" \ + --data @indicator_update.json +``` + +### 参数 + +| 参数 | 必填 | 说明 | +|------------------|----|--------------------------------------------------------------------| +| `--indicator-id` | 是 | 指标 ID(从 list 接口获取) | +| `--data` | 是 | JSON 请求体,包含要更新的字段。支持 `@文件路径` 从文件读取。 | +| `--user-id-type` | 否 | 用户 ID 类型 | + +### 请求体字段 + +根据需要更新的字段选择传入,支持增量更新: + +| 字段 | 类型 | 适用实体 | 说明 | +|-----------------------------|------|------|--------------------------------------------------------------------| +| `current_value` | number | 全部 | 当前值,范围 -99999999999 到 99999999999 | +| `current_value_calculate_type` | int | 全部 | 当前值计算方式:0=手动,1=基于 KR 进度(目标),2=基于拆解 KR 进度(KR) | +| `indicator_status` | int | 全部 | 状态:-1=未定义,0=正常,1=有风险,2=已延期。仅 `status_calculate_type=0` 时可修改 | +| `status_calculate_type` | int | 全部 | 状态计算方式:0=手动,1=自动(进度+时间),2=自动(最高风险 KR)。目标支持 0/1/2,KR 支持 0/1 | +| `start_value` | number | KR | 起始值。目标不支持修改 | +| `target_value` | number | KR | 目标值。目标不支持修改;有承接记录的 KR 不支持修改 | +| `unit` | object | KR | 单位。目标不支持修改;有承接记录的 KR 不支持修改 | + +### 单位 (`unit`) 格式 + +```json +{ + "unit": { + "unit_type": 0, // 0=公共单位,1=自定义单位 + "unit_value": "PERCENT" // 公共单位枚举:PERCENT、NONE、YUAN、DOLLAR;自定义单位:最长5字符 + } +} +``` + +### 限制说明 + +- **目标指标**:不支持修改 `start_value`、`target_value`、`unit` +- **关键结果指标**:有承接记录的 KR 不支持修改 `target_value`、`unit` +- **自动计算的指标**:`current_value_calculate_type != 0` 时,不能手动修改 `current_value` +- **自动状态的指标**:`status_calculate_type != 0` 时,不能手动修改 `indicator_status` + +--- + +## 完整工作流示例 + +### 场景:更新关键结果的指标当前值和状态 + +1. **查询关键结果的指标**(获取 `indicator_id` 和当前配置) + ```bash + lark-cli okr key_result.indicators list \ + --key-result-id 7652569715131075780 + ``` + +2. **检查指标配置**,确认: + - `current_value_calculate_type` 为 0(手动更新)才能修改 `current_value` + - `status_calculate_type` 为 0(手动更新)才能修改 `indicator_status` + +3. **更新指标** + ```bash + lark-cli okr indicators patch \ + --indicator-id "ind-123" \ + --data '{ + "current_value": 65.0, + "current_value_calculate_type": 0, + "indicator_status": 1, + "status_calculate_type": 0 + }' + ``` + +4. **验证更新结果** + ```bash + lark-cli okr key_result.indicators list \ + --key-result-id 7652569715131075780 + ``` + +### 场景:修改关键结果指标的目标值和单位 + +```bash +# 1. 查询获取 indicator_id +lark-cli okr key_result.indicators list --key-result-id 7652569715131075780 + +# 2. 更新目标值和单位 +lark-cli okr indicators patch \ + --indicator-id 7652569715131075781 \ + --data '{ + "target_value": 500, + "unit": {"unit_type": 0, "unit_value": "YUAN"} + }' +``` + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 +- [okr +indicator-update](lark-okr-indicator-update.md) -- 快捷更新指标当前值(推荐) diff --git a/skills/lark-okr/references/lark-okr-patch.md b/skills/lark-okr/references/lark-okr-patch.md new file mode 100644 index 00000000..867d527f --- /dev/null +++ b/skills/lark-okr/references/lark-okr-patch.md @@ -0,0 +1,104 @@ +# okr +patch + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +部分更新 OKR 目标(Objective)或关键结果(Key Result)的 content、notes、score、deadline 字段。支持增量更新,只需提供要修改的字段。 + +## 推荐命令 + +```bash +# 更新目标的 content(默认 simple 风格,半纯文本格式) +lark-cli okr +patch \ + --level objective \ + --target-id 1234567890123456789 \ + --content '{"text":"更新后的目标内容","mention":["ou_123"]}' + +# 更新关键结果的分数(0.0-1.0 的一位小数) +lark-cli okr +patch \ + --level key-result \ + --target-id 2345678901234567890 \ + --score 0.7 + +# 同时更新目标的多个字段(richtext 风格,完整 ContentBlock 格式) +lark-cli okr +patch \ + --level objective \ + --target-id 1234567890123456789 \ + --style richtext \ + --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"更新后的目标内容"}}]}}]}' \ + --notes '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"更新后的备注"}}]}}]}' \ + --score 0.5 \ + --deadline 1735776000000 + +# 预览 API 调用而不实际执行 +lark-cli okr +patch \ + --level objective \ + --target-id 1234567890123456789 \ + --content '{"text":"测试更新"}' \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|----------------|----|-----------|--------------------------------------------------------------------------------------------------------------------------------------| +| `--level` | 是 | — | 更新级别:`objective`(目标) \| `key-result`(关键结果) | +| `--target-id` | 是 | — | 目标 ID 或关键结果 ID(int64 类型,正整数) | +| `--style` | 否 | `simple` | 输入风格:`simple`(半纯文本 JSON,推荐) \| `richtext`(完整 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md) 了解两种格式。 | +| `--content` | 否¹ | — | 内容。根据 `--style` 指定格式。支持 `@文件路径` 从文件读取。 | +| `--notes` | 否¹ | — | 备注(仅 `--level=objective` 时支持)。根据 `--style` 指定格式。支持 `@文件路径` 从文件读取。 | +| `--score` | 否¹ | — | 分数值,0-1 之间,最多一位小数(如 0.5、1.0)。 | +| `--deadline` | 否¹ | — | 截止时间,毫秒级时间戳(如 1735776000000)。 | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | + +> ¹ 至少需要提供 `--content`、`--notes`、`--score`、`--deadline` 中的一个字段。 + +## 工作流程 + +1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。 +2. 确定要更新的字段: + - **content/notes**:构造内容 + - **推荐**:使用 `simple` 风格(默认),构造 SemiPlainContent JSON:`{"text":"内容","mention":["ou_xxx"]}` + - 如需复杂格式:使用 `richtext` 风格,构造 ContentBlock JSON。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 + - **score**:0-1 之间的数字,最多一位小数(如 0.3、0.7、1.0) + - **deadline**:毫秒级时间戳 +3. 执行 `lark-cli okr +patch --level objective --target-id "..." --content "..."`。 +4. 报告结果:更新的级别、目标 ID、以及哪些字段被更新。 + +## 输出 + +返回 JSON: + +```json +{ + "level": "objective", + "target_id": "1234567890123456789", + "patched": { + "content": true, + "notes": true, + "score": true, + "deadline": true + } +} +``` + +其中 `patched` 对象中的每个字段表示该字段是否被更新。 + +## 注意事项 + +- **`--notes` 仅适用于目标**:关键结果(key-result)不支持 notes 字段,使用时会报错。 +- **score 格式**:必须在 0-1 之间,且最多一位小数(如 0.5 正确,0.51 错误)。 +- **严格验证**:输入格式严格根据 `--style` 值验证,不会自动检测。使用 ContentBlock JSON 时必须指定 `--style richtext`。 +- **simple 风格输入限制**:simple 风格的输入不支持 `docs` 和 `images` 字段,如需包含文档或图片请使用 `richtext` 风格。 + +## 关于 1001001 错误 + +有时,当你涉及修改目标或关键结果的分数时,即使输入的参数完全正确, +patch 也会返回 1001001 错误(invalid parameters)。 +这可能是因为在用户的租户设置中停用了目标/关键结果的分数功能,或禁用了目标分数的手动计算。此时可以先去掉 --score 参数再修改,并向用户确认是否启用了对应的功能。 + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口) +- [ContentBlock 格式](lark-okr-contentblock.md) -- content/notes 使用的富文本格式 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-okr/references/lark-okr-progress-create.md b/skills/lark-okr/references/lark-okr-progress-create.md index 3731f922..dd56a8e9 100644 --- a/skills/lark-okr/references/lark-okr-progress-create.md +++ b/skills/lark-okr/references/lark-okr-progress-create.md @@ -7,15 +7,16 @@ ## 推荐命令 ```bash -# 为目标创建进展记录 +# 为目标创建进展记录(默认 simple 风格,半纯文本格式) lark-cli okr +progress-create \ - --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"本周完成了核心模块开发"}}]}}]}' \ + --content '{"text":"本周完成了核心模块开发","mention":["ou_123"]}' \ --target-id 1234567890123456789 \ --target-type objective -# 为关键结果创建进展记录(带进度百分比和状态) +# 为关键结果创建进展记录(richtext 风格,完整 ContentBlock 格式) lark-cli okr +progress-create \ --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"指标已达到 80%"}}]}}]}' \ + --style richtext \ --target-id 2345678901234567891 \ --target-type key_result \ --progress-percent 80 \ @@ -32,7 +33,8 @@ lark-cli okr +progress-create \ | 参数 | 必填 | 默认值 | 说明 | |----------------------|----|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------| -| `--content` | 是 | — | 进展内容,ContentBlock JSON 格式。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--content` | 是 | — | 进展内容。根据 `--style` 指定格式:`simple` 风格为 SemiPlainContent JSON,`richtext` 风格为 ContentBlock JSON。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--style` | 否 | `simple` | 输入风格:`simple`(半纯文本 JSON,推荐) \| `richtext`(完整 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md) 了解两种格式。 | | `--target-id` | 是 | — | 目标 ID 或关键结果 ID(int64 类型,正整数) | | `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` | | `--progress-percent` | 否 | — | 进度百分比(-99999999999 - 99999999999)。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 | @@ -46,7 +48,9 @@ lark-cli okr +progress-create \ ## 工作流程 1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。 -2. 构造 ContentBlock JSON 格式的进展内容。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 +2. 构造进展内容: + - **推荐**:使用 `simple` 风格(默认),构造 SemiPlainContent JSON:`{"text":"内容","mention":["ou_xxx"]}`,mention 中提及的用户会统一连接在文本末尾。 + - 如需复杂格式:使用 `richtext` 风格,构造 ContentBlock JSON。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。若需要插入图片/飞书文档或复杂文本格式,则必须使用 richtext 风格 3. 执行 `lark-cli okr +progress-create --content "..." --target-id "..." --target-type objective`。 4. 报告结果:新创建的进展记录 ID、修改时间等。 diff --git a/skills/lark-okr/references/lark-okr-progress-get.md b/skills/lark-okr/references/lark-okr-progress-get.md index ddc5e29e..e65010be 100644 --- a/skills/lark-okr/references/lark-okr-progress-get.md +++ b/skills/lark-okr/references/lark-okr-progress-get.md @@ -7,9 +7,12 @@ ## 推荐命令 ```bash -# 获取指定 ID 的进展记录 +# 获取指定 ID 的进展记录(默认 simple 风格,半纯文本格式) lark-cli okr +progress-get --progress-id 1234567890123456789 +# 获取指定 ID 的进展记录(richtext 风格,原始 ContentBlock JSON) +lark-cli okr +progress-get --progress-id 1234567890123456789 --style richtext + # 使用特定的用户 ID 类型 lark-cli okr +progress-get --progress-id 1234567890123456789 --user-id-type open_id @@ -19,12 +22,13 @@ lark-cli okr +progress-get --progress-id 1234567890123456789 --dry-run ## 参数 -| 参数 | 必填 | 默认值 | 说明 | -|------------------|----|-----------|-----------------------------------------------| -| `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) | -| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | -| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | -| `--format` | 否 | `json` | 输出格式。 | +| 参数 | 必填 | 默认值 | 说明 | +|------------------|----|-------------|--------------------------------------------------------------------| +| `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) | +| `--style` | 否 | `simple` | 输出风格:`simple`(半纯文本 SemiPlainContent,推荐) \| `richtext`(原始 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | ## 工作流程 @@ -34,26 +38,53 @@ lark-cli okr +progress-get --progress-id 1234567890123456789 --dry-run ## 输出 -返回 JSON: +返回 JSON,`content` 字段格式由 `--style` 控制: + +### `--style simple`(默认)输出示例: ```json { "progress": { "progress_id": "1234567890123456789", "modify_time": "2025-01-15 10:30:00", - "content": "{...}", + "content": { + "text": "已完成 80% 的开发工作 @{ou_zhangsan} ", + "mention": ["ou_zhangsan"], + "docs": [], + "images": [] + }, "progress_rate": { "percent": 75.0, "status": "normal" } - } + }, + "style": "simple" +} +``` + +### `--style richtext` 输出示例: + +```json +{ + "progress": { + "progress_id": "1234567890123456789", + "modify_time": "2025-01-15 10:30:00", + "content": "{\"blocks\":[{\"block_element_type\":\"paragraph\",\"paragraph\":{\"elements\":[{\"paragraph_element_type\":\"textRun\",\"text_run\":{\"text\":\"已完成 80% 的开发工作 \"}},{\"paragraph_element_type\":\"mention\",\"mention\":{\"user_id\":\"ou_zhangsan\"}}]}}]}", + "progress_rate": { + "percent": 75.0, + "status": "normal" + } + }, + "style": "richtext" } ``` 其中: -- `content` 字段是 JSON 字符串,为 OKR ContentBlock - 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。 +- `content` 字段格式由 `--style` 控制: + - `--style simple`(默认):`SemiPlainContent` 对象,包含 `text`、`mention`、`docs`、`images` 字段。`text` 中包含 `@{userID}` 占位符用于标识 mention 位置。 + - `--style richtext`:JSON 字符串,为 OKR ContentBlock 富文本格式 +- 请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解两种格式的详细信息。 - `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。 ## 参考 diff --git a/skills/lark-okr/references/lark-okr-progress-update.md b/skills/lark-okr/references/lark-okr-progress-update.md index a047da84..7491ba7f 100644 --- a/skills/lark-okr/references/lark-okr-progress-update.md +++ b/skills/lark-okr/references/lark-okr-progress-update.md @@ -7,15 +7,16 @@ ## 推荐命令 ```bash -# 更新进展记录内容 +# 更新进展记录内容(默认 simple 风格,半纯文本格式) lark-cli okr +progress-update \ --progress-id 1234567890123456789 \ - --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"更新后的进展内容"}}]}}]}' + --content '{"text":"更新后的进展内容","mention":["ou_123"]}' -# 更新进展记录内容并同时更新进度 +# 更新进展记录内容并同时更新进度(richtext 风格,完整 ContentBlock 格式) lark-cli okr +progress-update \ --progress-id 1234567890123456789 \ --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"进度已更新至 90%"}}]}}]}' \ + --style richtext \ --progress-percent 90 \ --progress-status normal @@ -27,7 +28,7 @@ lark-cli okr +progress-update \ # 预览 API 调用而不实际执行 lark-cli okr +progress-update \ --progress-id 1234567890123456789 \ - --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test"}}]}}]}' \ + --content '{"text":"test"}' \ --dry-run ``` @@ -36,7 +37,8 @@ lark-cli okr +progress-update \ | 参数 | 必填 | 默认值 | 说明 | |----------------------|----|-----------|----------------------------------------------------------------------------------------------------------------| | `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) | -| `--content` | 是 | — | 进展内容,ContentBlock JSON 格式。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--content` | 是 | — | 进展内容。根据 `--style` 指定格式:`simple` 风格为 SemiPlainContent JSON,`richtext` 风格为 ContentBlock JSON。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--style` | 否 | `simple` | 输入风格:`simple`(半纯文本 JSON,推荐) \| `richtext`(完整 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md) 了解两种格式。 | | `--progress-percent` | 否 | — | 进度百分比(-99999999999 - 99999999999)。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 | | `--progress-status` | 否 | — | 进度状态:`normal`(正常) \| `overdue`(逾期) \| `done`(已完成)。仅在指定 `--progress-percent` 时生效。 | | `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | @@ -46,7 +48,9 @@ lark-cli okr +progress-update \ ## 工作流程 1. 使用 `+progress-get` 获取要更新的进展记录的 ID 和当前内容。 -2. 修改 ContentBlock JSON 格式的进展内容。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 +2. 修改进展内容: + - **推荐**:使用 `simple` 风格(默认),构造 SemiPlainContent JSON:`{"text":"内容","mention":["ou_xxx"]}`,mention 中提及的用户会统一连接在文本末尾。 + - 如需复杂格式:使用 `richtext` 风格,构造 ContentBlock JSON。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。若需要插入图片/飞书文档或复杂文本格式,则必须使用 richtext 风格 3. 执行 `lark-cli okr +progress-update --progress-id "..." --content "..."`。 4. 报告结果:更新后的进展记录 ID、修改时间、进度百分比等。 diff --git a/tests/cli_e2e/okr/okr_cycle_detail_test.go b/tests/cli_e2e/okr/okr_cycle_detail_test.go index 00949b03..c878d984 100644 --- a/tests/cli_e2e/okr/okr_cycle_detail_test.go +++ b/tests/cli_e2e/okr/okr_cycle_detail_test.go @@ -35,6 +35,48 @@ func TestOKR_CycleDetailDryRun(t *testing.T) { assert.True(t, strings.Contains(output, "123456"), "dry-run should contain cycle-id, got: %s", output) } +// TestOKR_CycleDetailDryRun_SimpleStyle validates +cycle-detail dry-run with --style simple. +func TestOKR_CycleDetailDryRun_SimpleStyle(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+cycle-detail", + "--cycle-id", "123456", + "--style", "simple", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain API path, got: %s", output) +} + +// TestOKR_CycleDetailDryRun_RichTextStyle validates +cycle-detail dry-run with --style richtext. +func TestOKR_CycleDetailDryRun_RichTextStyle(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+cycle-detail", + "--cycle-id", "123456", + "--style", "richtext", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v2/cycles/123456/objectives"), "dry-run should contain API path, got: %s", output) +} + func setDryRunConfigEnv(t *testing.T) { t.Helper() t.Setenv("LARKSUITE_CLI_APP_ID", "cli_dryrun_test") diff --git a/tests/cli_e2e/okr/okr_progress_test.go b/tests/cli_e2e/okr/okr_progress_test.go index d3b0816c..ab804414 100644 --- a/tests/cli_e2e/okr/okr_progress_test.go +++ b/tests/cli_e2e/okr/okr_progress_test.go @@ -74,6 +74,7 @@ func TestOKR_ProgressCreateDryRun(t *testing.T) { Args: []string{ "okr", "+progress-create", "--content", `{"blocks":[{"type":"text","text":"test progress"}]}`, + "--style", "richtext", "--target-id", "123456789", "--target-type", "objective", "--dry-run", @@ -86,6 +87,10 @@ func TestOKR_ProgressCreateDryRun(t *testing.T) { assert.True(t, strings.Contains(output, "/open-apis/okr/v1/progress_records/"), "dry-run should contain API path, got: %s", output) assert.True(t, strings.Contains(output, "POST"), "dry-run should contain POST method, got: %s", output) assert.True(t, strings.Contains(output, "123456789"), "dry-run should contain target-id, got: %s", output) + // Validate request body contains expected fields + assert.True(t, strings.Contains(output, `"target_id"`), "dry-run should contain target_id in request body, got: %s", output) + assert.True(t, strings.Contains(output, `"target_type"`), "dry-run should contain target_type in request body, got: %s", output) + assert.True(t, strings.Contains(output, `"content"`), "dry-run should contain content in request body, got: %s", output) } // TestOKR_ProgressCreateDryRun_WithProgress validates +progress-create dry-run with progress rate. @@ -98,6 +103,7 @@ func TestOKR_ProgressCreateDryRun_WithProgress(t *testing.T) { Args: []string{ "okr", "+progress-create", "--content", `{"blocks":[{"type":"text","text":"test progress"}]}`, + "--style", "richtext", "--target-id", "123456789", "--target-type", "key_result", "--progress-percent", "75", @@ -156,6 +162,48 @@ func TestOKR_ProgressGetDryRun_WithUserIDType(t *testing.T) { assert.True(t, strings.Contains(output, "/open-apis/okr/v1/progress_records/987654321"), "dry-run should contain API path, got: %s", output) } +// TestOKR_ProgressGetDryRun_SimpleStyle validates +progress-get dry-run with --style simple. +func TestOKR_ProgressGetDryRun_SimpleStyle(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+progress-get", + "--progress-id", "123456789", + "--style", "simple", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v1/progress_records/123456789"), "dry-run should contain API path, got: %s", output) +} + +// TestOKR_ProgressGetDryRun_RichTextStyle validates +progress-get dry-run with --style richtext. +func TestOKR_ProgressGetDryRun_RichTextStyle(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+progress-get", + "--progress-id", "987654321", + "--style", "richtext", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v1/progress_records/987654321"), "dry-run should contain API path, got: %s", output) +} + // --- Progress Update Dry-run E2E tests --- // TestOKR_ProgressUpdateDryRun validates +progress-update dry-run output contains the correct method and API path. @@ -169,6 +217,7 @@ func TestOKR_ProgressUpdateDryRun(t *testing.T) { "okr", "+progress-update", "--progress-id", "123456789", "--content", `{"blocks":[{"type":"text","text":"updated progress"}]}`, + "--style", "richtext", "--dry-run", }, }) @@ -191,6 +240,7 @@ func TestOKR_ProgressUpdateDryRun_WithProgress(t *testing.T) { "okr", "+progress-update", "--progress-id", "123456789", "--content", `{"blocks":[{"type":"text","text":"updated progress"}]}`, + "--style", "richtext", "--progress-percent", "100", "--progress-status", "done", "--dry-run", diff --git a/tests/cli_e2e/okr/okr_shortcuts_test.go b/tests/cli_e2e/okr/okr_shortcuts_test.go index eb39fb42..2c83df3b 100644 --- a/tests/cli_e2e/okr/okr_shortcuts_test.go +++ b/tests/cli_e2e/okr/okr_shortcuts_test.go @@ -159,6 +159,113 @@ func TestOKR_WeightDryRun_KR(t *testing.T) { assert.True(t, strings.Contains(output, "789"), "dry-run should contain objective-id, got: %s", output) } +// --- Dry-run E2E tests for +patch --- + +// TestOKR_PatchDryRun_Objective validates +patch dry-run for objective with content. +func TestOKR_PatchDryRun_Objective(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+patch", + "--level", "objective", + "--target-id", "123", + "--content", `{"text":"updated content","mention":["ou_123"]}`, + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "PATCH"), "dry-run should contain PATCH method, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/123"), "dry-run should contain objective API path, got: %s", output) + assert.True(t, strings.Contains(output, "content=true"), "dry-run should show content patch, got: %s", output) +} + +// TestOKR_PatchDryRun_Objective_AllFields validates +patch dry-run for objective with all fields. +func TestOKR_PatchDryRun_Objective_AllFields(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", "+patch", + "--level", "objective", + "--target-id", "456", + "--style", "simple", + "--content", `{"text":"new content"}`, + "--notes", `{"text":"new notes"}`, + "--score", "0.7", + "--deadline", "1735776000000", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v2/objectives/456"), "dry-run should contain objective API path, got: %s", output) + assert.True(t, strings.Contains(output, "content=true"), "dry-run should show content patch, got: %s", output) + assert.True(t, strings.Contains(output, "notes=true"), "dry-run should show notes patch, got: %s", output) + assert.True(t, strings.Contains(output, "score=true"), "dry-run should show score patch, got: %s", output) + assert.True(t, strings.Contains(output, "deadline=true"), "dry-run should show deadline patch, got: %s", output) + assert.True(t, strings.Contains(output, `"user_id_type": "open_id"`), "dry-run should contain user_id_type param, got: %s", output) +} + +// TestOKR_PatchDryRun_KeyResult validates +patch dry-run for key result. +func TestOKR_PatchDryRun_KeyResult(t *testing.T) { + setDryRunConfigEnv(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "okr", "+patch", + "--level", "key-result", + "--target-id", "789", + "--score", "0.5", + "--user-id-type", "user_id", + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "PATCH"), "dry-run should contain PATCH method, got: %s", output) + assert.True(t, strings.Contains(output, "/open-apis/okr/v2/key_results/789"), "dry-run should contain key_result API path, got: %s", output) + assert.True(t, strings.Contains(output, "score=true"), "dry-run should show score patch, got: %s", output) + assert.True(t, strings.Contains(output, `"user_id_type": "user_id"`), "dry-run should contain user_id_type param, got: %s", output) +} + +// TestOKR_PatchDryRun_KeyResult_RichText validates +patch dry-run for key result with richtext style. +func TestOKR_PatchDryRun_KeyResult_RichText(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", "+patch", + "--level", "key-result", + "--target-id", "101", + "--style", "richtext", + "--content", `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"updated"}}]}}]}`, + "--dry-run", + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := result.Stdout + assert.True(t, strings.Contains(output, "/open-apis/okr/v2/key_results/101"), "dry-run should contain key_result API path, got: %s", output) + assert.True(t, strings.Contains(output, "content=true"), "dry-run should show content patch, got: %s", output) +} + // --- Live E2E tests (require user token, skip otherwise) --- // getTestCycleID returns the test cycle ID from env var, or skips the test.