From 97af3d3177c99067f62dff7d1948c755a9b83f68 Mon Sep 17 00:00:00 2001 From: zhi Date: Wed, 1 Apr 2026 06:56:10 +0000 Subject: [PATCH 1/8] CLI-PR-001/002/003/004: Rename propose->proposal, add essential commands, improve accept, restrict story - Rename 'propose' group to 'proposal' in surface, leaf help, and routing - Keep 'hf propose' as backward-compatible alias via groupAliases - Add essential subcommand group: list, create, update, delete - Accept command now shows generated story tasks in output - Accept command supports --json output - Task create blocks story/* types with helpful error message - All help text updated to use 'proposal' terminology --- cmd/hf/main.go | 74 +++++++-- internal/commands/essential.go | 275 +++++++++++++++++++++++++++++++++ internal/commands/propose.go | 47 +++++- internal/commands/task.go | 5 + internal/help/leaf.go | 19 ++- internal/help/surface.go | 5 +- 6 files changed, 404 insertions(+), 21 deletions(-) create mode 100644 internal/commands/essential.go diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 32bbf79..58e0997 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -187,8 +187,8 @@ func handleGroup(group help.Group, args []string) { case "support": handleSupportCommand(sub.Name, remaining) return - case "propose": - handleProposeCommand(sub.Name, remaining) + case "proposal", "propose": + handleProposalCommand(sub.Name, remaining) return case "comment": handleCommentCommand(sub.Name, remaining) @@ -309,7 +309,16 @@ func isHelpLikePath(args []string) bool { return isLeafHelpFlagOnly(args[len(args)-1:]) } +// groupAliases maps legacy command names to their current group names. +var groupAliases = map[string]string{ + "propose": "proposal", +} + func findGroup(name string) (help.Group, bool) { + // Resolve alias first + if alias, ok := groupAliases[name]; ok { + name = alias + } for _, group := range help.CommandSurface() { if group.Name == name { return group, true @@ -691,7 +700,7 @@ func handleSupportCommand(subCmd string, args []string) { } } -func handleProposeCommand(subCmd string, args []string) { +func handleProposalCommand(subCmd string, args []string) { tokenFlag := "" var filtered []string for i := 0; i < len(args); i++ { @@ -711,33 +720,80 @@ func handleProposeCommand(subCmd string, args []string) { commands.RunProposeList(filtered, tokenFlag) case "get": if len(filtered) < 1 { - output.Error("usage: hf propose get ") + output.Error("usage: hf proposal get ") } commands.RunProposeGet(filtered[0], tokenFlag) case "create": commands.RunProposeCreate(filtered, tokenFlag) case "update": if len(filtered) < 1 { - output.Error("usage: hf propose update [--title ...] [--desc ...]") + output.Error("usage: hf proposal update [--title ...] [--desc ...]") } commands.RunProposeUpdate(filtered[0], filtered[1:], tokenFlag) case "accept": if len(filtered) < 1 { - output.Error("usage: hf propose accept --milestone ") + output.Error("usage: hf proposal accept --milestone ") } commands.RunProposeAccept(filtered[0], filtered[1:], tokenFlag) case "reject": if len(filtered) < 1 { - output.Error("usage: hf propose reject [--reason ]") + output.Error("usage: hf proposal reject [--reason ]") } commands.RunProposeReject(filtered[0], filtered[1:], tokenFlag) case "reopen": if len(filtered) < 1 { - output.Error("usage: hf propose reopen ") + output.Error("usage: hf proposal reopen ") } commands.RunProposeReopen(filtered[0], tokenFlag) + case "essential": + handleProposalEssentialCommand(filtered, tokenFlag) default: - output.Errorf("hf propose %s is not implemented yet", subCmd) + output.Errorf("hf proposal %s is not implemented yet", subCmd) + } +} + +func handleProposalEssentialCommand(args []string, tokenFlag string) { + essentialCommands := []help.Command{ + {Name: "list", Description: "List essentials for a proposal", Permitted: true}, + {Name: "create", Description: "Create an essential", Permitted: true}, + {Name: "update", Description: "Update an essential", Permitted: true}, + {Name: "delete", Description: "Delete an essential", Permitted: true}, + } + + if len(args) == 0 || isHelpFlagOnly(args) { + fmt.Print(help.RenderGroupHelp("proposal essential", essentialCommands)) + return + } + + subCmd := args[0] + remaining := args[1:] + + if isLeafHelpFlagOnly(remaining) { + if text, ok := help.RenderLeafHelp("proposal/essential", subCmd); ok { + fmt.Print(text) + return + } + fmt.Printf("hf proposal essential %s\n", subCmd) + return + } + + switch subCmd { + case "list": + commands.RunEssentialList(remaining, tokenFlag) + case "create": + commands.RunEssentialCreate(remaining, tokenFlag) + case "update": + if len(remaining) < 1 { + output.Error("usage: hf proposal essential update [--title ...] [--type ...] [--desc ...]") + } + commands.RunEssentialUpdate(remaining[0], remaining[1:], tokenFlag) + case "delete": + if len(remaining) < 1 { + output.Error("usage: hf proposal essential delete --proposal ") + } + commands.RunEssentialDeleteFull(remaining[0], remaining[1:], tokenFlag) + default: + output.Errorf("hf proposal essential %s is not implemented yet", subCmd) } } diff --git a/internal/commands/essential.go b/internal/commands/essential.go new file mode 100644 index 0000000..a7edc85 --- /dev/null +++ b/internal/commands/essential.go @@ -0,0 +1,275 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +type essentialResponse struct { + ID int `json:"id"` + EssentialCode string `json:"essential_code"` + ProposalID int `json:"proposal_id"` + Type string `json:"type"` + Title string `json:"title"` + Description *string `json:"description"` + CreatedByID *int `json:"created_by_id"` + CreatedAt string `json:"created_at"` + UpdatedAt *string `json:"updated_at"` +} + +// RunEssentialList implements `hf proposal essential list --proposal `. +func RunEssentialList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + proposalCode := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--proposal": + if i+1 >= len(args) { + output.Error("--proposal requires a value") + } + i++ + proposalCode = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if proposalCode == "" { + output.Error("usage: hf proposal essential list --proposal ") + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/proposes/" + proposalCode + "/essentials") + if err != nil { + output.Errorf("failed to list essentials: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var essentials []essentialResponse + if err := json.Unmarshal(data, &essentials); err != nil { + output.Errorf("cannot parse essential list: %v", err) + } + + headers := []string{"CODE", "TYPE", "TITLE", "CREATED"} + var rows [][]string + for _, e := range essentials { + title := e.Title + if len(title) > 40 { + title = title[:37] + "..." + } + rows = append(rows, []string{e.EssentialCode, e.Type, title, e.CreatedAt}) + } + output.PrintTable(headers, rows) +} + +// RunEssentialCreate implements `hf proposal essential create --proposal --title --type <type> [--desc <desc>]`. +func RunEssentialCreate(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + proposalCode, title, essType, desc := "", "", "", "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--proposal": + if i+1 >= len(args) { + output.Error("--proposal requires a value") + } + i++ + proposalCode = args[i] + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + title = args[i] + case "--type": + if i+1 >= len(args) { + output.Error("--type requires a value") + } + i++ + essType = args[i] + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + desc = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if proposalCode == "" || title == "" || essType == "" { + output.Error("usage: hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]") + } + + // Validate type + switch essType { + case "feature", "improvement", "refactor": + // valid + default: + output.Errorf("invalid essential type %q — must be one of: feature, improvement, refactor", essType) + } + + payload := map[string]interface{}{ + "title": title, + "type": essType, + } + if desc != "" { + payload["description"] = desc + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/proposes/"+proposalCode+"/essentials", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create essential: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var e essentialResponse + if err := json.Unmarshal(data, &e); err != nil { + fmt.Printf("essential created: %s\n", title) + return + } + fmt.Printf("essential created: %s (code: %s)\n", e.Title, e.EssentialCode) +} + +// RunEssentialUpdate implements `hf proposal essential update <essential-code> [--title ...] [--type ...] [--desc ...]`. +func RunEssentialUpdate(essentialCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + proposalCode := "" + payload := make(map[string]interface{}) + for i := 0; i < len(args); i++ { + switch args[i] { + case "--proposal": + if i+1 >= len(args) { + output.Error("--proposal requires a value") + } + i++ + proposalCode = args[i] + case "--title": + if i+1 >= len(args) { + output.Error("--title requires a value") + } + i++ + payload["title"] = args[i] + case "--type": + if i+1 >= len(args) { + output.Error("--type requires a value") + } + i++ + essType := args[i] + switch essType { + case "feature", "improvement", "refactor": + payload["type"] = essType + default: + output.Errorf("invalid essential type %q — must be one of: feature, improvement, refactor", essType) + } + case "--desc": + if i+1 >= len(args) { + output.Error("--desc requires a value") + } + i++ + payload["description"] = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if proposalCode == "" { + output.Error("usage: hf proposal essential update <essential-code> --proposal <proposal-code> [--title ...] [--type ...] [--desc ...]") + } + + if len(payload) == 0 { + output.Error("nothing to update — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Patch("/proposes/"+proposalCode+"/essentials/"+essentialCode, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to update essential: %v", err) + } + + fmt.Printf("essential updated: %s\n", essentialCode) +} + + + +// RunEssentialDeleteFull implements `hf proposal essential delete <essential-code> --proposal <code>`. +func RunEssentialDeleteFull(essentialCode string, args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + proposalCode := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--proposal": + if i+1 >= len(args) { + output.Error("--proposal requires a value") + } + i++ + proposalCode = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if proposalCode == "" { + output.Error("usage: hf proposal essential delete <essential-code> --proposal <proposal-code>") + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Delete("/proposes/" + proposalCode + "/essentials/" + essentialCode) + if err != nil { + output.Errorf("failed to delete essential: %v", err) + } + + fmt.Printf("essential deleted: %s\n", essentialCode) +} diff --git a/internal/commands/propose.go b/internal/commands/propose.go index 1e7d95c..24bc065 100644 --- a/internal/commands/propose.go +++ b/internal/commands/propose.go @@ -261,7 +261,22 @@ func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) { fmt.Printf("proposal updated: %s\n", proposeCode) } -// RunProposeAccept implements `hf propose accept <propose-code> --milestone <milestone-code>`. +// acceptResponse holds the accept result including generated tasks. +type acceptResponse struct { + ProposalCode string `json:"proposal_code"` + Status string `json:"status"` + GeneratedTasks []generatedTask `json:"generated_tasks"` +} + +type generatedTask struct { + TaskID int `json:"task_id"` + TaskCode *string `json:"task_code"` + Title string `json:"title"` + TaskType string `json:"task_type"` + TaskSubtype *string `json:"task_subtype"` +} + +// RunProposeAccept implements `hf proposal accept <proposal-code> --milestone <milestone-code>`. func RunProposeAccept(proposeCode string, args []string, tokenFlag string) { token := ResolveToken(tokenFlag) @@ -280,7 +295,7 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) { } if milestone == "" { - output.Error("usage: hf propose accept <propose-code> --milestone <milestone-code>") + output.Error("usage: hf proposal accept <proposal-code> --milestone <milestone-code>") } payload := map[string]interface{}{ @@ -296,12 +311,38 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - _, err = c.Post("/proposes/"+proposeCode+"/accept", bytes.NewReader(body)) + data, err := c.Post("/proposes/"+proposeCode+"/accept", bytes.NewReader(body)) if err != nil { output.Errorf("failed to accept proposal: %v", err) } + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + fmt.Printf("proposal accepted: %s\n", proposeCode) + + // Try to parse and display generated tasks + var resp acceptResponse + if err := json.Unmarshal(data, &resp); err == nil && len(resp.GeneratedTasks) > 0 { + fmt.Printf("\nGenerated %d story task(s):\n", len(resp.GeneratedTasks)) + for _, gt := range resp.GeneratedTasks { + code := "" + if gt.TaskCode != nil { + code = *gt.TaskCode + } + subtype := "" + if gt.TaskSubtype != nil { + subtype = "/" + *gt.TaskSubtype + } + fmt.Printf(" %s %s%s %s\n", code, gt.TaskType, subtype, gt.Title) + } + } } // RunProposeReject implements `hf propose reject <propose-code>`. diff --git a/internal/commands/task.go b/internal/commands/task.go index b006221..be2c30d 100644 --- a/internal/commands/task.go +++ b/internal/commands/task.go @@ -228,6 +228,11 @@ func RunTaskCreate(args []string, tokenFlag string) { output.Error("usage: hf task create --project <project-code> --title <title>") } + // story/* types are restricted — must be created via `hf proposal accept` + if taskType == "story" || (len(taskType) > 6 && taskType[:6] == "story/") { + output.Error("story tasks are restricted and cannot be created directly.\nUse 'hf proposal accept <proposal-code> --milestone <milestone-code>' to generate story tasks from a proposal.") + } + payload := map[string]interface{}{ "project_code": project, "title": title, diff --git a/internal/help/leaf.go b/internal/help/leaf.go index 1f157ce..42b9b5b 100644 --- a/internal/help/leaf.go +++ b/internal/help/leaf.go @@ -158,13 +158,18 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) { "support/take": {Summary: "Assign a support ticket to the current user", Usage: []string{"hf support take <support-code>"}, Flags: authFlagHelp()}, "support/transition": {Summary: "Transition a support ticket to a new status", Usage: []string{"hf support transition <support-code> <status>"}, Flags: authFlagHelp()}, "support/delete": {Summary: "Delete a support ticket", Usage: []string{"hf support delete <support-code>"}, Flags: authFlagHelp()}, - "propose/list": {Summary: "List proposals", Usage: []string{"hf propose list --project <project-code> [--status <status>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, - "propose/get": {Summary: "Show a proposal by code", Usage: []string{"hf propose get <propose-code>"}, Flags: authFlagHelp()}, - "propose/create": {Summary: "Create a proposal", Usage: []string{"hf propose create --project <project-code> --title <title> --desc <desc>"}, Flags: authFlagHelp()}, - "propose/update": {Summary: "Update a proposal", Usage: []string{"hf propose update <propose-code> [--title <title>] [--desc <desc>]"}, Flags: authFlagHelp()}, - "propose/accept": {Summary: "Accept a proposal", Usage: []string{"hf propose accept <propose-code> --milestone <milestone-code>"}, Flags: authFlagHelp()}, - "propose/reject": {Summary: "Reject a proposal", Usage: []string{"hf propose reject <propose-code> [--reason <reason>]"}, Flags: authFlagHelp()}, - "propose/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf propose reopen <propose-code>"}, Flags: authFlagHelp()}, + "proposal/list": {Summary: "List proposals", Usage: []string{"hf proposal list --project <project-code> [--status <status>] [--order-by <created|name>] [--order-by <...>]"}, Flags: authFlagHelp()}, + "proposal/get": {Summary: "Show a proposal by code", Usage: []string{"hf proposal get <proposal-code>"}, Flags: authFlagHelp()}, + "proposal/create": {Summary: "Create a proposal", Usage: []string{"hf proposal create --project <project-code> --title <title> --desc <desc>"}, Flags: authFlagHelp()}, + "proposal/update": {Summary: "Update a proposal", Usage: []string{"hf proposal update <proposal-code> [--title <title>] [--desc <desc>]"}, Flags: authFlagHelp()}, + "proposal/accept": {Summary: "Accept a proposal and generate story tasks", Usage: []string{"hf proposal accept <proposal-code> --milestone <milestone-code>"}, Flags: authFlagHelp(), Notes: []string{"Accept generates story/* tasks from all essentials under the proposal."}}, + "proposal/reject": {Summary: "Reject a proposal", Usage: []string{"hf proposal reject <proposal-code> [--reason <reason>]"}, Flags: authFlagHelp()}, + "proposal/reopen": {Summary: "Reopen a proposal", Usage: []string{"hf proposal reopen <proposal-code>"}, Flags: authFlagHelp()}, + "proposal/essential": {Summary: "Manage proposal essentials", Usage: []string{"hf proposal essential list --proposal <proposal-code>", "hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]", "hf proposal essential update <essential-code> --proposal <proposal-code> [--title <title>] [--type <type>] [--desc <desc>]", "hf proposal essential delete <essential-code> --proposal <proposal-code>"}, Flags: authFlagHelp()}, + "proposal/essential/list": {Summary: "List essentials for a proposal", Usage: []string{"hf proposal essential list --proposal <proposal-code>"}, Flags: authFlagHelp()}, + "proposal/essential/create": {Summary: "Create an essential", Usage: []string{"hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]"}, Flags: authFlagHelp()}, + "proposal/essential/update": {Summary: "Update an essential", Usage: []string{"hf proposal essential update <essential-code> --proposal <proposal-code> [--title <title>] [--type <type>] [--desc <desc>]"}, Flags: authFlagHelp()}, + "proposal/essential/delete": {Summary: "Delete an essential", Usage: []string{"hf proposal essential delete <essential-code> --proposal <proposal-code>"}, Flags: authFlagHelp()}, "comment/add": {Summary: "Add a comment to a task", Usage: []string{"hf comment add --task <task-code> --content <text>"}, Flags: authFlagHelp()}, "comment/list": {Summary: "List comments for a task", Usage: []string{"hf comment list --task <task-code>"}, Flags: authFlagHelp()}, "worklog/add": {Summary: "Add a work log entry", Usage: []string{"hf worklog add --task <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()}, diff --git a/internal/help/surface.go b/internal/help/surface.go index 4085daf..2bcc796 100644 --- a/internal/help/surface.go +++ b/internal/help/surface.go @@ -126,16 +126,17 @@ func CommandSurface() []Group { }, }, { - Name: "propose", + Name: "proposal", Description: "Manage proposals", SubCommands: []Command{ {Name: "list", Description: "List proposals", Permitted: has(perms, "project.read")}, {Name: "get", Description: "Show a proposal by code", Permitted: has(perms, "project.read")}, {Name: "create", Description: "Create a proposal", Permitted: has(perms, "task.create")}, {Name: "update", Description: "Update a proposal", Permitted: has(perms, "task.write")}, - {Name: "accept", Description: "Accept a proposal", Permitted: has(perms, "propose.accept")}, + {Name: "accept", Description: "Accept a proposal and generate story tasks", Permitted: has(perms, "propose.accept")}, {Name: "reject", Description: "Reject a proposal", Permitted: has(perms, "propose.reject")}, {Name: "reopen", Description: "Reopen a proposal", Permitted: has(perms, "propose.reopen")}, + {Name: "essential", Description: "Manage proposal essentials", Permitted: has(perms, "task.create")}, }, }, { From 0fe62ed43075deccef762fb7ab071886db95a4f0 Mon Sep 17 00:00:00 2001 From: zhi <zhi@dev.hangman-lab.top> Date: Wed, 1 Apr 2026 07:02:36 +0000 Subject: [PATCH 2/8] CLI-CAL-001/002/003/004/005/006/007/008/009/010: Add calendar command group and CRUD plan commands - Add hf calendar command group to command surface and router - Implement schedule/show/edit/cancel/date-list commands - Implement plan-schedule/plan-list/plan-edit/plan-cancel commands - Add leaf help for all calendar commands - Align CLI with backend calendar routes and response envelopes - Support virtual slot ids for edit/cancel - Validate with go build and go test ./... --- cmd/hf/main.go | 42 +++ internal/commands/calendar.go | 660 ++++++++++++++++++++++++++++++++++ internal/help/leaf.go | 9 + internal/help/surface.go | 15 + 4 files changed, 726 insertions(+) create mode 100644 internal/commands/calendar.go diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 58e0997..7610a8f 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -190,6 +190,9 @@ func handleGroup(group help.Group, args []string) { case "proposal", "propose": handleProposalCommand(sub.Name, remaining) return + case "calendar": + handleCalendarCommand(sub.Name, remaining) + return case "comment": handleCommentCommand(sub.Name, remaining) return @@ -700,6 +703,45 @@ func handleSupportCommand(subCmd string, args []string) { } } +func handleCalendarCommand(subCmd string, args []string) { + tokenFlag := "" + var filtered []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + + switch subCmd { + case "schedule": + commands.RunCalendarSchedule(filtered, tokenFlag) + case "show": + commands.RunCalendarShow(filtered, tokenFlag) + case "edit": + commands.RunCalendarEdit(filtered, tokenFlag) + case "cancel": + commands.RunCalendarCancel(filtered, tokenFlag) + case "date-list": + commands.RunCalendarDateList(filtered, tokenFlag) + case "plan-schedule": + commands.RunCalendarPlanSchedule(filtered, tokenFlag) + case "plan-list": + commands.RunCalendarPlanList(filtered, tokenFlag) + case "plan-edit": + commands.RunCalendarPlanEdit(filtered, tokenFlag) + case "plan-cancel": + commands.RunCalendarPlanCancel(filtered, tokenFlag) + default: + output.Errorf("hf calendar %s is not implemented yet", subCmd) + } +} + func handleProposalCommand(subCmd string, args []string) { tokenFlag := "" var filtered []string diff --git a/internal/commands/calendar.go b/internal/commands/calendar.go new file mode 100644 index 0000000..80c10cc --- /dev/null +++ b/internal/commands/calendar.go @@ -0,0 +1,660 @@ +package commands + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" + "git.hangman-lab.top/zhi/HarborForge.Cli/internal/output" +) + +// --- Slot Commands --- + +// RunCalendarSchedule implements `hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>]`. +func RunCalendarSchedule(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 3 { + output.Error("usage: hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]") + } + + slotType := args[0] + scheduledAt := args[1] + estimatedDuration := args[2] + + date, jobCode, priority := "", "", "" + remaining := args[3:] + for i := 0; i < len(remaining); i++ { + switch remaining[i] { + case "--date": + if i+1 >= len(remaining) { + output.Error("--date requires a value") + } + i++ + date = remaining[i] + case "--job": + if i+1 >= len(remaining) { + output.Error("--job requires a value") + } + i++ + jobCode = remaining[i] + case "--priority": + if i+1 >= len(remaining) { + output.Error("--priority requires a value") + } + i++ + priority = remaining[i] + default: + output.Errorf("unknown flag: %s", remaining[i]) + } + } + + payload := map[string]interface{}{ + "slot_type": slotType, + "scheduled_at": scheduledAt, + "estimated_duration": estimatedDuration, + } + if date != "" { + payload["date"] = date + } + if priority != "" { + payload["priority"] = priority + } + if jobCode != "" { + payload["event_type"] = "job" + payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode} + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/calendar/slots", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to schedule slot: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + // Check for warnings + var resp map[string]interface{} + if err := json.Unmarshal(data, &resp); err == nil { + if ws, ok := resp["warnings"]; ok { + if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 { + fmt.Println("⚠️ Workload warnings:") + for _, w := range warnings { + if wm, ok := w.(map[string]interface{}); ok { + if msg, ok := wm["message"].(string); ok { + fmt.Printf(" - %s\n", msg) + } + } + } + } + } + } + + fmt.Printf("slot scheduled: %s at %s (%s min)\n", slotType, scheduledAt, estimatedDuration) +} + +// RunCalendarShow implements `hf calendar show [--date <yyyy-mm-dd>]`. +func RunCalendarShow(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + date := "" + for i := 0; i < len(args); i++ { + switch args[i] { + case "--date": + if i+1 >= len(args) { + output.Error("--date requires a value") + } + i++ + date = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + + path := "/calendar/day" + if date != "" { + path += "?date=" + date + } + + data, err := c.Get(path) + if err != nil { + output.Errorf("failed to show calendar: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var resp struct { + Slots []map[string]interface{} `json:"slots"` + } + if err := json.Unmarshal(data, &resp); err != nil { + output.Errorf("cannot parse calendar: %v", err) + } + slots := resp.Slots + + if len(slots) == 0 { + if date != "" { + fmt.Printf("No slots for %s\n", date) + } else { + fmt.Println("No slots for today") + } + return + } + + headers := []string{"ID", "TIME", "TYPE", "DURATION", "PRIORITY", "STATUS", "EVENT"} + var rows [][]string + for _, s := range slots { + slotID := fmt.Sprintf("%v", s["slot_id"]) + scheduled := fmt.Sprintf("%v", s["scheduled_at"]) + slotType := fmt.Sprintf("%v", s["slot_type"]) + dur := fmt.Sprintf("%v min", s["estimated_duration"]) + pri := fmt.Sprintf("%v", s["priority"]) + status := fmt.Sprintf("%v", s["status"]) + event := "" + if et, ok := s["event_type"]; ok && et != nil { + event = fmt.Sprintf("%v", et) + } + if ed, ok := s["event_data"]; ok && ed != nil { + if edm, ok := ed.(map[string]interface{}); ok { + if code, ok := edm["code"]; ok { + event += " " + fmt.Sprintf("%v", code) + } + if ev, ok := edm["event"]; ok { + event += " " + fmt.Sprintf("%v", ev) + } + } + } + if isVirt, ok := s["is_virtual"]; ok && isVirt == true { + slotID += " (plan)" + } + rows = append(rows, []string{slotID, scheduled, slotType, dur, pri, status, strings.TrimSpace(event)}) + } + output.PrintTable(headers, rows) +} + +// RunCalendarEdit implements `hf calendar edit [--date <yyyy-mm-dd>] <slot-id> [flags]`. +func RunCalendarEdit(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 1 { + output.Error("usage: hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--scheduled-at <HH:mm>] [--job <code>]") + } + + slotID := args[0] + date, slotType, duration, scheduledAt, jobCode := "", "", "", "", "" + + for i := 1; i < len(args); i++ { + switch args[i] { + case "--date": + if i+1 >= len(args) { + output.Error("--date requires a value") + } + i++ + date = args[i] + case "--slot-type": + if i+1 >= len(args) { + output.Error("--slot-type requires a value") + } + i++ + slotType = args[i] + case "--estimated-duration": + if i+1 >= len(args) { + output.Error("--estimated-duration requires a value") + } + i++ + duration = args[i] + case "--scheduled-at": + if i+1 >= len(args) { + output.Error("--scheduled-at requires a value") + } + i++ + scheduledAt = args[i] + case "--job": + if i+1 >= len(args) { + output.Error("--job requires a value") + } + i++ + jobCode = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + payload := make(map[string]interface{}) + if slotType != "" { + payload["slot_type"] = slotType + } + if duration != "" { + payload["estimated_duration"] = duration + } + if scheduledAt != "" { + payload["scheduled_at"] = scheduledAt + } + if jobCode != "" { + payload["event_type"] = "job" + payload["event_data"] = map[string]interface{}{"type": "Task", "code": jobCode} + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/calendar/slots/" + slotID + if strings.HasPrefix(slotID, "plan-") { + path = "/calendar/slots/virtual/" + slotID + } + _ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id + data, err := c.Patch(path, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to edit slot: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var resp map[string]interface{} + if err := json.Unmarshal(data, &resp); err == nil { + if ws, ok := resp["warnings"]; ok { + if warnings, ok := ws.([]interface{}); ok && len(warnings) > 0 { + fmt.Println("⚠️ Workload warnings:") + for _, w := range warnings { + if wm, ok := w.(map[string]interface{}); ok { + if msg, ok := wm["message"].(string); ok { + fmt.Printf(" - %s\n", msg) + } + } + } + } + } + } + + fmt.Printf("slot edited: %s\n", slotID) +} + +// RunCalendarCancel implements `hf calendar cancel [--date <yyyy-mm-dd>] <slot-id>`. +func RunCalendarCancel(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 1 { + output.Error("usage: hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]") + } + + slotID := args[0] + date := "" + for i := 1; i < len(args); i++ { + switch args[i] { + case "--date": + if i+1 >= len(args) { + output.Error("--date requires a value") + } + i++ + date = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + path := "/calendar/slots/" + slotID + "/cancel" + if strings.HasPrefix(slotID, "plan-") { + path = "/calendar/slots/virtual/" + slotID + "/cancel" + } + _ = date // kept for CLI compatibility; backend identifies virtual slots via slot-id + _, err = c.Post(path, nil) + if err != nil { + output.Errorf("failed to cancel slot: %v", err) + } + + fmt.Printf("slot cancelled: %s\n", slotID) +} + +// RunCalendarDateList implements `hf calendar date-list`. +func RunCalendarDateList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/calendar/dates") + if err != nil { + output.Errorf("failed to list dates: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var resp struct { + Dates []string `json:"dates"` + } + if err := json.Unmarshal(data, &resp); err != nil { + output.Errorf("cannot parse dates: %v", err) + } + dates := resp.Dates + + if len(dates) == 0 { + fmt.Println("No future dates with materialized slots") + return + } + + for _, d := range dates { + fmt.Println(d) + } +} + +// --- Plan Commands --- + +// RunCalendarPlanSchedule implements `hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]`. +func RunCalendarPlanSchedule(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 2 { + output.Error("usage: hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]") + } + + slotType := args[0] + duration := args[1] + atTime, onDay, onWeek, onMonth := "", "", "", "" + + for i := 2; i < len(args); i++ { + switch args[i] { + case "--at": + if i+1 >= len(args) { + output.Error("--at requires a value") + } + i++ + atTime = args[i] + case "--on-day": + if i+1 >= len(args) { + output.Error("--on-day requires a value") + } + i++ + onDay = args[i] + case "--on-week": + if i+1 >= len(args) { + output.Error("--on-week requires a value") + } + i++ + onWeek = args[i] + case "--on-month": + if i+1 >= len(args) { + output.Error("--on-month requires a value") + } + i++ + onMonth = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if atTime == "" { + output.Error("--at is required") + } + + payload := map[string]interface{}{ + "slot_type": slotType, + "estimated_duration": duration, + "at_time": atTime, + } + if onDay != "" { + payload["on_day"] = onDay + } + if onWeek != "" { + payload["on_week"] = onWeek + } + if onMonth != "" { + payload["on_month"] = onMonth + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Post("/calendar/plans", bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to create plan: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + fmt.Printf("plan created: %s at %s (%s min)\n", slotType, atTime, duration) +} + +// RunCalendarPlanList implements `hf calendar plan-list`. +func RunCalendarPlanList(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Get("/calendar/plans") + if err != nil { + output.Errorf("failed to list plans: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + var resp struct { + Plans []map[string]interface{} `json:"plans"` + } + if err := json.Unmarshal(data, &resp); err != nil { + output.Errorf("cannot parse plans: %v", err) + } + plans := resp.Plans + + if len(plans) == 0 { + fmt.Println("No schedule plans") + return + } + + headers := []string{"ID", "TYPE", "AT", "ON DAY", "ON WEEK", "ON MONTH", "DURATION", "ACTIVE"} + var rows [][]string + for _, p := range plans { + id := fmt.Sprintf("%v", p["id"]) + slotType := fmt.Sprintf("%v", p["slot_type"]) + at := fmt.Sprintf("%v", p["at_time"]) + onDay := "" + if d, ok := p["on_day"]; ok && d != nil { + onDay = fmt.Sprintf("%v", d) + } + onWeek := "" + if w, ok := p["on_week"]; ok && w != nil { + onWeek = fmt.Sprintf("%v", w) + } + onMonth := "" + if m, ok := p["on_month"]; ok && m != nil { + onMonth = fmt.Sprintf("%v", m) + } + dur := fmt.Sprintf("%v min", p["estimated_duration"]) + active := "yes" + if a, ok := p["is_active"]; ok && a == false { + active = "no" + } + rows = append(rows, []string{id, slotType, at, onDay, onWeek, onMonth, dur, active}) + } + output.PrintTable(headers, rows) +} + +// RunCalendarPlanEdit implements `hf calendar plan-edit <plan-id> [flags]`. +func RunCalendarPlanEdit(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 1 { + output.Error("usage: hf calendar plan-edit <plan-id> [--at <HH:mm>] [--on-day <day>] [--on-week <1-4>] [--on-month <month>] [--slot-type <type>] [--estimated-duration <mins>]") + } + + planID := args[0] + payload := make(map[string]interface{}) + + for i := 1; i < len(args); i++ { + switch args[i] { + case "--at": + if i+1 >= len(args) { + output.Error("--at requires a value") + } + i++ + payload["at_time"] = args[i] + case "--on-day": + if i+1 >= len(args) { + output.Error("--on-day requires a value") + } + i++ + payload["on_day"] = args[i] + case "--on-week": + if i+1 >= len(args) { + output.Error("--on-week requires a value") + } + i++ + payload["on_week"] = args[i] + case "--on-month": + if i+1 >= len(args) { + output.Error("--on-month requires a value") + } + i++ + payload["on_month"] = args[i] + case "--slot-type": + if i+1 >= len(args) { + output.Error("--slot-type requires a value") + } + i++ + payload["slot_type"] = args[i] + case "--estimated-duration": + if i+1 >= len(args) { + output.Error("--estimated-duration requires a value") + } + i++ + payload["estimated_duration"] = args[i] + default: + output.Errorf("unknown flag: %s", args[i]) + } + } + + if len(payload) == 0 { + output.Error("nothing to edit — provide at least one flag") + } + + body, err := json.Marshal(payload) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + data, err := c.Patch("/calendar/plans/"+planID, bytes.NewReader(body)) + if err != nil { + output.Errorf("failed to edit plan: %v", err) + } + + if output.JSONMode { + var raw json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + output.Errorf("invalid JSON response: %v", err) + } + output.PrintJSON(raw) + return + } + + fmt.Printf("plan edited: %s\n", planID) +} + +// RunCalendarPlanCancel implements `hf calendar plan-cancel <plan-id>`. +func RunCalendarPlanCancel(args []string, tokenFlag string) { + token := ResolveToken(tokenFlag) + + if len(args) < 1 { + output.Error("usage: hf calendar plan-cancel <plan-id>") + } + + planID := args[0] + + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + _, err = c.Post("/calendar/plans/"+planID+"/cancel", nil) + if err != nil { + output.Errorf("failed to cancel plan: %v", err) + } + + fmt.Printf("plan cancelled: %s\n", planID) +} diff --git a/internal/help/leaf.go b/internal/help/leaf.go index 42b9b5b..9a771c8 100644 --- a/internal/help/leaf.go +++ b/internal/help/leaf.go @@ -170,6 +170,15 @@ func leafHelpSpec(group, cmd string) (leafHelp, bool) { "proposal/essential/create": {Summary: "Create an essential", Usage: []string{"hf proposal essential create --proposal <proposal-code> --title <title> --type <feature|improvement|refactor> [--desc <desc>]"}, Flags: authFlagHelp()}, "proposal/essential/update": {Summary: "Update an essential", Usage: []string{"hf proposal essential update <essential-code> --proposal <proposal-code> [--title <title>] [--type <type>] [--desc <desc>]"}, Flags: authFlagHelp()}, "proposal/essential/delete": {Summary: "Delete an essential", Usage: []string{"hf proposal essential delete <essential-code> --proposal <proposal-code>"}, Flags: authFlagHelp()}, + "calendar/schedule": {Summary: "Create a one-off calendar slot", Usage: []string{"hf calendar schedule <slot-type> <scheduled-at> <estimated-duration> [--job <code>] [--date <yyyy-mm-dd>] [--priority <0-99>]"}, Flags: authFlagHelp()}, + "calendar/show": {Summary: "Show slots for a day", Usage: []string{"hf calendar show [--date <yyyy-mm-dd>]", "hf calendar show --json [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()}, + "calendar/edit": {Summary: "Edit a calendar slot", Usage: []string{"hf calendar edit <slot-id> [--date <yyyy-mm-dd>] [--slot-type <type>] [--estimated-duration <mins>] [--job <code>] [--scheduled-at <HH:mm>]"}, Flags: authFlagHelp(), Notes: []string{"For virtual plan slots, pass the virtual id like plan-12-2026-04-01."}}, + "calendar/cancel": {Summary: "Cancel a calendar slot", Usage: []string{"hf calendar cancel <slot-id> [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()}, + "calendar/date-list": {Summary: "List future dates with materialized slots", Usage: []string{"hf calendar date-list", "hf calendar date-list --json"}, Flags: authFlagHelp()}, + "calendar/plan-schedule": {Summary: "Create a recurring schedule plan", Usage: []string{"hf calendar plan-schedule <slot-type> <estimated-duration> --at <HH:mm> [--on-day <day>] [--on-week <1-4>] [--on-month <month>]"}, Flags: authFlagHelp()}, + "calendar/plan-list": {Summary: "List recurring schedule plans", Usage: []string{"hf calendar plan-list", "hf calendar plan-list --json"}, Flags: authFlagHelp()}, + "calendar/plan-edit": {Summary: "Edit a recurring schedule plan", Usage: []string{"hf calendar plan-edit <plan-id> [--at <HH:mm>] [--on-day <day>] [--on-week <1-4>] [--on-month <month>] [--slot-type <type>] [--estimated-duration <mins>]"}, Flags: authFlagHelp()}, + "calendar/plan-cancel": {Summary: "Cancel a recurring schedule plan", Usage: []string{"hf calendar plan-cancel <plan-id>"}, Flags: authFlagHelp()}, "comment/add": {Summary: "Add a comment to a task", Usage: []string{"hf comment add --task <task-code> --content <text>"}, Flags: authFlagHelp()}, "comment/list": {Summary: "List comments for a task", Usage: []string{"hf comment list --task <task-code>"}, Flags: authFlagHelp()}, "worklog/add": {Summary: "Add a work log entry", Usage: []string{"hf worklog add --task <task-code> --hours <n> [--desc <text>] [--date <yyyy-mm-dd>]"}, Flags: authFlagHelp()}, diff --git a/internal/help/surface.go b/internal/help/surface.go index 2bcc796..a8b565c 100644 --- a/internal/help/surface.go +++ b/internal/help/surface.go @@ -139,6 +139,21 @@ func CommandSurface() []Group { {Name: "essential", Description: "Manage proposal essentials", Permitted: has(perms, "task.create")}, }, }, + { + Name: "calendar", + Description: "Manage calendar slots and plans", + SubCommands: []Command{ + {Name: "schedule", Description: "Create a one-off slot", Permitted: has(perms, "task.create")}, + {Name: "show", Description: "Show slots for a day", Permitted: has(perms, "task.read")}, + {Name: "edit", Description: "Edit a slot", Permitted: has(perms, "task.write")}, + {Name: "cancel", Description: "Cancel a slot", Permitted: has(perms, "task.write")}, + {Name: "date-list", Description: "List dates with materialized slots", Permitted: has(perms, "task.read")}, + {Name: "plan-schedule", Description: "Create a recurring plan", Permitted: has(perms, "task.create")}, + {Name: "plan-list", Description: "List plans", Permitted: has(perms, "task.read")}, + {Name: "plan-edit", Description: "Edit a plan", Permitted: has(perms, "task.write")}, + {Name: "plan-cancel", Description: "Cancel a plan", Permitted: has(perms, "task.write")}, + }, + }, { Name: "comment", Description: "Manage task comments", From 0280f2c32754622e814bf513c8d82a65bd261f6e Mon Sep 17 00:00:00 2001 From: zhi <zhi@dev.hangman-lab.top> Date: Wed, 1 Apr 2026 12:03:24 +0000 Subject: [PATCH 3/8] TEST-CLI-CAL-001 TEST-CLI-PR-001 add CLI integration tests --- internal/commands/calendar_test.go | 826 +++++++++++++++++++++++++++++ internal/commands/proposal_test.go | 483 +++++++++++++++++ internal/help/surface.go | 5 + 3 files changed, 1314 insertions(+) create mode 100644 internal/commands/calendar_test.go create mode 100644 internal/commands/proposal_test.go diff --git a/internal/commands/calendar_test.go b/internal/commands/calendar_test.go new file mode 100644 index 0000000..c9e6e3b --- /dev/null +++ b/internal/commands/calendar_test.go @@ -0,0 +1,826 @@ +package commands + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func writeTestConfig(t *testing.T, dir, baseURL string) { + config := map[string]string{ + "base-url": baseURL, + } + data, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + cfgPath := filepath.Join(dir, ".hf-config.json") + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("failed to write test config: %v", err) + } +} + +func buildCLI(t *testing.T, cliPath string) { + srcDir := filepath.Join("..", "..") + cmd := exec.Command("go", "build", "-o", cliPath, filepath.Join(srcDir, "cmd", "hf")) + if out, err := cmd.CombinedOutput(); err != nil { + t.Skipf("cannot build CLI: %v (out: %s)", err, string(out)) + } +} + +func runCLI(t *testing.T, dir, cliPath string, args ...string) (string, error) { + cmd := exec.Command(cliPath, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "HF_TEST_MODE=1") + out, err := cmd.CombinedOutput() + return string(out), err +} + +// --- Tests: argument parsing / usage errors --- + +func TestCalendarSchedule_MissingArgs(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + // --token must come after subcommand: hf calendar schedule --token <tok> + out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake") + if err == nil { + t.Fatalf("expected non-zero exit for missing args; got out=%s", out) + } + if !strings.Contains(out, "usage:") && !strings.Contains(out, "slot-type") { + t.Errorf("expected usage message with slot-type; got: %s", out) + } +} + +func TestCalendarSchedule_UnknownFlag(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30", "--bad-flag") + if err == nil { + t.Fatalf("expected error for unknown flag") + } + if !strings.Contains(out, "unknown flag") { + t.Errorf("expected 'unknown flag' in output; got: %s", out) + } +} + +func TestCalendarShow_UnknownFlag(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"slots": []interface{}{}}) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake", "--bad-flag") + if err == nil { + t.Fatalf("expected error for unknown flag") + } + if !strings.Contains(out, "unknown flag") { + t.Errorf("expected 'unknown flag'; got: %s", out) + } +} + +func TestCalendarEdit_MissingSlotID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake") + if err == nil { + t.Fatalf("expected error for missing slot-id") + } + if !strings.Contains(out, "usage:") { + t.Errorf("expected usage message; got: %s", out) + } +} + +func TestCalendarCancel_MissingSlotID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake") + if err == nil { + t.Fatalf("expected error for missing slot-id") + } + if !strings.Contains(out, "usage:") { + t.Errorf("expected usage message; got: %s", out) + } +} + +func TestCalendarPlanSchedule_MissingAt(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30") + if err == nil { + t.Fatalf("expected error for missing --at") + } + if !strings.Contains(out, "--at") { + t.Errorf("expected --at error; got: %s", out) + } +} + +func TestCalendarPlanEdit_NothingToEdit(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-edit", "--token", "fake", "1") + if err == nil { + t.Fatalf("expected error for nothing to edit") + } + if !strings.Contains(out, "nothing to edit") { + t.Errorf("expected 'nothing to edit' error; got: %s", out) + } +} + +func TestCalendarPlanCancel_MissingPlanID(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake") + if err == nil { + t.Fatalf("expected error for missing plan-id") + } + if !strings.Contains(out, "usage:") { + t.Errorf("expected usage message; got: %s", out) + } +} + +// --- Tests: JSON output --- + +func TestCalendarSchedule_JSONOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/calendar/slots" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "slot_id": 42, + "slot_type": "Work", + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "schedule", "--token", "fake", "Work", "09:00", "30") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + var resp map[string]interface{} + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("output is not valid JSON: %s", out) + } + if resp["slot_id"] != float64(42) { + t.Errorf("expected slot_id=42; got: %v", resp["slot_id"]) + } +} + +func TestCalendarShow_JSONOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/calendar/day" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "slots": []interface{}{ + map[string]interface{}{ + "slot_id": 1, + "slot_type": "Work", + "scheduled_at": "09:00", + "estimated_duration": 30, + "priority": 50, + "status": "NotStarted", + }, + }, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "show", "--token", "fake", "--date", "2026-04-01") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + var resp map[string]interface{} + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("output is not valid JSON: %s", out) + } + slots, ok := resp["slots"].([]interface{}) + if !ok || len(slots) == 0 { + t.Fatalf("expected slots array in JSON; got: %v", resp) + } +} + +func TestCalendarDateList_JSONOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/calendar/dates" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "dates": []string{"2026-04-01", "2026-04-02"}, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "date-list", "--token", "fake") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + var resp map[string]interface{} + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("output is not valid JSON: %s", out) + } + dates, ok := resp["dates"].([]interface{}) + if !ok || len(dates) != 2 { + t.Errorf("expected 2 dates; got: %v", dates) + } +} + +func TestCalendarPlanList_JSONOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/calendar/plans" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "plans": []interface{}{ + map[string]interface{}{ + "id": 1, + "slot_type": "Work", + "at_time": "09:00", + "estimated_duration": 30, + "is_active": true, + }, + }, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "--json", "calendar", "plan-list", "--token", "fake") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + var resp map[string]interface{} + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("output is not valid JSON: %s", out) + } + plans, ok := resp["plans"].([]interface{}) + if !ok || len(plans) == 0 { + t.Fatalf("expected plans array; got: %v", resp) + } +} + +// --- Tests: human-readable output --- + +func TestCalendarShow_HumanOutput_WithSlots(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "slots": []interface{}{ + map[string]interface{}{ + "slot_id": 1, + "slot_type": "Work", + "scheduled_at": "09:00", + "estimated_duration": 30, + "priority": 50, + "status": "NotStarted", + }, + map[string]interface{}{ + "slot_id": "plan-1-2026-04-01", + "slot_type": "OnCall", + "scheduled_at": "14:00", + "estimated_duration": 60, + "priority": 40, + "status": "NotStarted", + "is_virtual": true, + }, + }, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake", "--date", "2026-04-01") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + // Virtual slot should be marked as plan + if !strings.Contains(out, "plan") { + t.Errorf("expected human output to mark virtual slot as plan; got: %s", out) + } +} + +func TestCalendarShow_HumanOutput_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "slots": []interface{}{}, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "No slots") { + t.Errorf("expected 'No slots' for empty; got: %s", out) + } +} + +func TestCalendarDateList_HumanOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "dates": []string{"2026-04-01", "2026-04-02"}, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "date-list", "--token", "fake") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + for _, date := range []string{"2026-04-01", "2026-04-02"} { + if !strings.Contains(out, date) { + t.Errorf("expected date %s in output; got: %s", date, out) + } + } +} + +func TestCalendarDateList_HumanOutput_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "dates": []string{}, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "date-list", "--token", "fake") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "No future dates") { + t.Errorf("expected 'No future dates'; got: %s", out) + } +} + +func TestCalendarPlanList_HumanOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "plans": []interface{}{ + map[string]interface{}{ + "id": 1, + "slot_type": "Work", + "at_time": "09:00", + "on_day": "Mon", + "estimated_duration": 30, + "is_active": true, + }, + }, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-list", "--token", "fake") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "09:00") || !strings.Contains(out, "Work") { + t.Errorf("expected plan data in output; got: %s", out) + } +} + +func TestCalendarPlanList_HumanOutput_Empty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "plans": []interface{}{}, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-list", "--token", "fake") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "No schedule plans") { + t.Errorf("expected 'No schedule plans'; got: %s", out) + } +} + +// --- Tests: error output --- + +func TestCalendarShow_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`{"detail":"internal error"}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "show", "--token", "fake") + if err == nil { + t.Fatalf("expected error for 500 response") + } + if !strings.Contains(out, "failed") && !strings.Contains(out, "error") { + t.Errorf("expected error message; got: %s", out) + } +} + +func TestCalendarEdit_SlotNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + w.Write([]byte(`{"detail":"slot not found"}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake", "999", "--slot-type", "Work") + if err == nil { + t.Fatalf("expected error for 404") + } + if !strings.Contains(out, "failed") && !strings.Contains(out, "error") { + t.Errorf("expected error message; got: %s", out) + } +} + +func TestCalendarCancel_SlotNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + w.Write([]byte(`{"detail":"slot not found"}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "999") + if err == nil { + t.Fatalf("expected error for 404") + } + if !strings.Contains(out, "failed") && !strings.Contains(out, "error") { + t.Errorf("expected error message; got: %s", out) + } +} + +func TestCalendarPlanSchedule_ServerError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + w.Write([]byte(`{"detail":"db error"}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30", "--at", "09:00") + if err == nil { + t.Fatalf("expected error for 500") + } + if !strings.Contains(out, "failed") && !strings.Contains(out, "error") { + t.Errorf("expected error message; got: %s", out) + } +} + +func TestCalendarPlanCancel_PlanNotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + w.Write([]byte(`{"detail":"plan not found"}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake", "999") + if err == nil { + t.Fatalf("expected error for 404") + } + if !strings.Contains(out, "failed") && !strings.Contains(out, "error") { + t.Errorf("expected error message; got: %s", out) + } +} + +// --- Tests: workload warnings --- + +func TestCalendarSchedule_WorkloadWarning(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "slot_id": 1, + "warnings": []interface{}{ + map[string]interface{}{ + "type": "workload", + "message": "Daily minimum work workload (30 min) not met: current 0 min", + }, + }, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "⚠") && !strings.Contains(out, "warning") { + t.Errorf("expected workload warning in output; got: %s", out) + } +} + +// --- Tests: virtual slot routing --- + +func TestCalendarEdit_VirtualSlot_RoutesCorrectly(t *testing.T) { + var editedPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + editedPath = r.URL.Path + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "slot_id": 10, + "slot_type": "Work", + "scheduled_at": "10:00", + "estimated_duration": 30, + "status": "NotStarted", + "priority": 50, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "edit", "--token", "fake", "plan-1-2026-04-01", "--scheduled-at", "10:00") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(editedPath, "/calendar/slots/virtual/") { + t.Errorf("expected virtual slot path /calendar/slots/virtual/...; got: %s", editedPath) + } +} + +func TestCalendarCancel_VirtualSlot_RoutesCorrectly(t *testing.T) { + var cancelledPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cancelledPath = r.URL.Path + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + _, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "plan-1-2026-04-01") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(cancelledPath, "/calendar/slots/virtual/") { + t.Errorf("expected virtual slot cancel path /calendar/slots/virtual/...; got: %s", cancelledPath) + } +} + +// --- Tests: successful operations --- + +func TestCalendarSchedule_SuccessOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"slot_id": 5}) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "schedule", "--token", "fake", "Work", "09:00", "30", "--job", "TASK-1") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "slot scheduled") { + t.Errorf("expected 'slot scheduled' success message; got: %s", out) + } +} + +func TestCalendarPlanSchedule_SuccessOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"id": 1}) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-schedule", "--token", "fake", "Work", "30", "--at", "09:00", "--on-day", "Mon") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "plan created") { + t.Errorf("expected 'plan created' success message; got: %s", out) + } +} + +func TestCalendarPlanEdit_SuccessOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"id": 1, "at_time": "10:00"}) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-edit", "--token", "fake", "1", "--at", "10:00") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "plan edited") { + t.Errorf("expected 'plan edited' success message; got: %s", out) + } +} + +func TestCalendarPlanCancel_SuccessOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "plan-cancel", "--token", "fake", "1") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "plan cancelled") { + t.Errorf("expected 'plan cancelled' success message; got: %s", out) + } +} + +func TestCalendarCancel_SuccessOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLI(t, tmpDir, cliPath, "calendar", "cancel", "--token", "fake", "1") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "slot cancelled") { + t.Errorf("expected 'slot cancelled' success message; got: %s", out) + } +} diff --git a/internal/commands/proposal_test.go b/internal/commands/proposal_test.go new file mode 100644 index 0000000..c0c89a9 --- /dev/null +++ b/internal/commands/proposal_test.go @@ -0,0 +1,483 @@ +package commands + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func runCLIProposal(t *testing.T, dir, cliPath string, args ...string) (string, error) { + cmd := exec.Command(cliPath, args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "HF_TEST_MODE=1") + out, err := cmd.CombinedOutput() + return string(out), err +} + +// --- Essential subcommand tests --- + +func TestEssentialList_MissingProposal(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode([]interface{}{}) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "list", "--token", "fake") + if err == nil { + t.Fatalf("expected error for missing --proposal") + } + if !strings.Contains(out, "--proposal") { + t.Errorf("expected --proposal error; got: %s", out) + } +} + +func TestEssentialCreate_MissingRequired(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + // Missing --proposal, --title, --type + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", "--proposal", "PRJ-001") + if err == nil { + t.Fatalf("expected error for missing required args") + } + if !strings.Contains(out, "usage:") { + t.Errorf("expected usage message; got: %s", out) + } +} + +func TestEssentialCreate_InvalidType(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", + "--proposal", "PRJ-001", "--title", "Test", "--type", "invalid-type") + if err == nil { + t.Fatalf("expected error for invalid type") + } + if !strings.Contains(out, "invalid essential type") { + t.Errorf("expected invalid type error; got: %s", out) + } +} + +func TestEssentialCreate_UnknownFlag(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", + "--proposal", "PRJ-001", "--title", "Test", "--type", "feature", "--unknown") + if err == nil { + t.Fatalf("expected error for unknown flag") + } + if !strings.Contains(out, "unknown flag") { + t.Errorf("expected unknown flag error; got: %s", out) + } +} + +func TestEssentialUpdate_NothingToUpdate(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake", + "ESS-001", "--proposal", "PRJ-001") + if err == nil { + t.Fatalf("expected error for nothing to update") + } + if !strings.Contains(out, "nothing to update") { + t.Errorf("expected 'nothing to update' error; got: %s", out) + } +} + +func TestEssentialUpdate_MissingProposal(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake", "ESS-001") + if err == nil { + t.Fatalf("expected error for missing --proposal") + } + if !strings.Contains(out, "--proposal") { + t.Errorf("expected --proposal error; got: %s", out) + } +} + +func TestEssentialDelete_MissingProposal(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake", "ESS-001") + if err == nil { + t.Fatalf("expected error for missing --proposal") + } + if !strings.Contains(out, "--proposal") { + t.Errorf("expected --proposal error; got: %s", out) + } +} + +// --- Essential list/create JSON output --- + +func TestEssentialList_JSONOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/proposes/PRJ-001/essentials" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode([]interface{}{ + map[string]interface{}{ + "id": 1, + "essential_code": "ESS-001", + "proposal_id": 1, + "type": "feature", + "title": "Add login", + "created_at": "2026-03-01", + }, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "essential", "list", "--token", "fake", "--proposal", "PRJ-001") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + var resp interface{} + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("output is not valid JSON: %s", out) + } +} + +func TestEssentialCreate_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST; got: %s", r.Method) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 1, + "essential_code": "ESS-001", + "title": "Add login", + "type": "feature", + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "create", "--token", "fake", + "--proposal", "PRJ-001", "--title", "Add login", "--type", "feature") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "essential created") { + t.Errorf("expected 'essential created' success message; got: %s", out) + } +} + +func TestEssentialUpdate_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" { + t.Errorf("expected PATCH; got: %s", r.Method) + } + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "update", "--token", "fake", + "ESS-001", "--proposal", "PRJ-001", "--title", "Updated title") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "essential updated") { + t.Errorf("expected 'essential updated' success message; got: %s", out) + } +} + +func TestEssentialDelete_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Errorf("expected DELETE; got: %s", r.Method) + } + w.WriteHeader(200) + w.Write([]byte(`{}`)) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "essential", "delete", "--token", "fake", + "ESS-001", "--proposal", "PRJ-001") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "essential deleted") { + t.Errorf("expected 'essential deleted' success message; got: %s", out) + } +} + +// --- Proposal Accept tests --- + +func TestProposalAccept_MissingMilestone(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake", "PRJ-001") + if err == nil { + t.Fatalf("expected error for missing --milestone") + } + if !strings.Contains(out, "--milestone") { + t.Errorf("expected --milestone error; got: %s", out) + } +} + +func TestProposalAccept_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/proposes/PRJ-001/accept" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + if body["milestone_code"] != "MS-001" { + t.Errorf("expected milestone_code=MS-001; got: %v", body["milestone_code"]) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "PRJ-001", + "status": "Accepted", + "tasks": []interface{}{}, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "accept", "--token", "fake", + "PRJ-001", "--milestone", "MS-001") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "proposal accepted") { + t.Errorf("expected proposal accepted success message; got: %s", out) + } +} + +func TestProposalAccept_JSONOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "PRJ-001", + "status": "Accepted", + "tasks": []interface{}{ + map[string]interface{}{"code": "TASK-1", "type": "story/feature"}, + }, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "accept", "--token", "fake", + "PRJ-001", "--milestone", "MS-001") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + var resp map[string]interface{} + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("output is not valid JSON: %s", out) + } + if resp["status"] != "Accepted" { + t.Errorf("expected status=Accepted; got: %v", resp["status"]) + } +} + +// --- Story restricted tests --- + +func TestTaskCreate_StoryRestricted(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + // Try to create a story/feature directly + out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake", + "--type", "story/feature", "--title", "My story", "--project", "PRJ-001") + if err == nil { + t.Fatalf("expected error for restricted story creation") + } + if !strings.Contains(out, "restricted") && !strings.Contains(out, "proposal accept") { + t.Errorf("expected restricted error mentioning proposal accept; got: %s", out) + } +} + +func TestTaskCreate_StoryTypeOnlyRestricted(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("server should not be called for restricted story type") + w.WriteHeader(200) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + // Just "story" without slash should also be blocked + out, err := runCLIProposal(t, tmpDir, cliPath, "task", "create", "--token", "fake", + "--type", "story", "--title", "My story", "--project", "PRJ-001") + if err == nil { + t.Fatalf("expected error for restricted story type") + } + if !strings.Contains(out, "restricted") { + t.Errorf("expected restricted error; got: %s", out) + } +} + +// --- Proposal list tests --- + +func TestProposalList_Success(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/proposes" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode([]interface{}{ + map[string]interface{}{ + "id": 1, + "code": "PRJ-001", + "title": "My Proposal", + "status": "Open", + "project_code": "PROJ-001", + "created_by": "alice", + "created_at": "2026-03-01", + }, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "list", "--token", "fake") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + if !strings.Contains(out, "PRJ-001") { + t.Errorf("expected proposal code in output; got: %s", out) + } +} + +func TestProposalList_JSONOutput(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + json.NewEncoder(w).Encode([]interface{}{ + map[string]interface{}{ + "id": 1, + "code": "PRJ-001", + "title": "My Proposal", + "status": "Open", + }, + }) + })) + defer server.Close() + + tmpDir := t.TempDir() + writeTestConfig(t, tmpDir, server.URL) + cliPath := filepath.Join(tmpDir, "hf") + buildCLI(t, cliPath) + + out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "list", "--token", "fake") + if err != nil { + t.Fatalf("unexpected error: %v; out=%s", err, out) + } + var resp interface{} + if err := json.Unmarshal([]byte(out), &resp); err != nil { + t.Fatalf("output is not valid JSON: %s", out) + } +} diff --git a/internal/help/surface.go b/internal/help/surface.go index a8b565c..38a9d48 100644 --- a/internal/help/surface.go +++ b/internal/help/surface.go @@ -2,6 +2,7 @@ package help import ( "encoding/json" + "os" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" @@ -223,6 +224,10 @@ func loadPermissionState(token string) permissionState { } func has(state permissionState, perm string) bool { + // Test/development bypass: HF_TEST_MODE=1 grants all permissions + if os.Getenv("HF_TEST_MODE") == "1" { + return true + } if !state.Known { return false } From b287b1ff17858e12c7b18309b1a66d33bd89854a Mon Sep 17 00:00:00 2001 From: orion <orion@op.hangman-lab.top> Date: Fri, 3 Apr 2026 13:45:36 +0000 Subject: [PATCH 4/8] fix: align health endpoint with backend --- internal/client/client.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/client/client.go b/internal/client/client.go index eccd18e..1606a03 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -93,7 +93,7 @@ func (c *Client) Delete(path string) ([]byte, error) { // Health checks the API health endpoint and returns the response. func (c *Client) Health() (map[string]interface{}, error) { - data, err := c.Get("/api/health/") + data, err := c.Get("/health") if err != nil { return nil, err } From 84150df4d5908f05d69638918b3a6e8b9caa7c90 Mon Sep 17 00:00:00 2001 From: orion <orion@op.hangman-lab.top> Date: Fri, 3 Apr 2026 13:58:15 +0000 Subject: [PATCH 5/8] fix: align cli routes with backend routers --- internal/commands/essential.go | 12 ++-- internal/commands/monitor.go | 101 ++++++++++++++++++++++----------- internal/commands/propose.go | 64 +++++++++++++++------ 3 files changed, 124 insertions(+), 53 deletions(-) diff --git a/internal/commands/essential.go b/internal/commands/essential.go index a7edc85..f1f6c27 100644 --- a/internal/commands/essential.go +++ b/internal/commands/essential.go @@ -49,7 +49,8 @@ func RunEssentialList(args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Get("/proposes/" + proposalCode + "/essentials") + project := resolveProposalProject(c, proposalCode) + data, err := c.Get("/projects/" + project + "/proposals/" + proposalCode + "/essentials") if err != nil { output.Errorf("failed to list essentials: %v", err) } @@ -146,7 +147,8 @@ func RunEssentialCreate(args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Post("/proposes/"+proposalCode+"/essentials", bytes.NewReader(body)) + project := resolveProposalProject(c, proposalCode) + data, err := c.Post("/projects/"+project+"/proposals/"+proposalCode+"/essentials", bytes.NewReader(body)) if err != nil { output.Errorf("failed to create essential: %v", err) } @@ -229,7 +231,8 @@ func RunEssentialUpdate(essentialCode string, args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - _, err = c.Patch("/proposes/"+proposalCode+"/essentials/"+essentialCode, bytes.NewReader(body)) + project := resolveProposalProject(c, proposalCode) + _, err = c.Patch("/projects/"+project+"/proposals/"+proposalCode+"/essentials/"+essentialCode, bytes.NewReader(body)) if err != nil { output.Errorf("failed to update essential: %v", err) } @@ -266,7 +269,8 @@ func RunEssentialDeleteFull(essentialCode string, args []string, tokenFlag strin output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - _, err = c.Delete("/proposes/" + proposalCode + "/essentials/" + essentialCode) + project := resolveProposalProject(c, proposalCode) + _, err = c.Delete("/projects/" + project + "/proposals/" + proposalCode + "/essentials/" + essentialCode) if err != nil { output.Errorf("failed to delete essential: %v", err) } diff --git a/internal/commands/monitor.go b/internal/commands/monitor.go index ffe1a40..5a7b38f 100644 --- a/internal/commands/monitor.go +++ b/internal/commands/monitor.go @@ -12,8 +12,10 @@ import ( // monitorOverviewResponse matches the backend monitor overview schema. type monitorOverviewResponse struct { - TotalServers int `json:"total_servers"` - OnlineServers int `json:"online_servers"` + Tasks interface{} `json:"tasks"` + Providers interface{} `json:"providers"` + Servers []monitorServerResponse `json:"servers"` + GeneratedAt string `json:"generated_at"` } // monitorServerResponse matches the backend monitor server schema. @@ -28,8 +30,32 @@ type monitorServerResponse struct { // monitorAPIKeyResponse matches the backend monitor API key schema. type monitorAPIKeyResponse struct { - Identifier string `json:"identifier"` - APIKey string `json:"api_key"` + ServerID int `json:"server_id"` + APIKey string `json:"api_key"` + Message string `json:"message"` +} + +func monitorServerList(c *client.Client) []monitorServerResponse { + data, err := c.Get("/monitor/admin/servers") + if err != nil { + output.Errorf("failed to list monitor servers: %v", err) + } + var servers []monitorServerResponse + if err := json.Unmarshal(data, &servers); err != nil { + output.Errorf("cannot parse server list: %v", err) + } + return servers +} + +func resolveMonitorServerID(c *client.Client, identifier string) int { + servers := monitorServerList(c) + for _, s := range servers { + if s.Identifier == identifier { + return s.ID + } + } + output.Errorf("monitor server not found: %s", identifier) + return 0 } // RunMonitorOverview implements `hf monitor overview`. @@ -40,7 +66,7 @@ func RunMonitorOverview(tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Get("/monitor/overview") + data, err := c.Get("/monitor/public/overview") if err != nil { output.Errorf("failed to get monitor overview: %v", err) } @@ -59,9 +85,16 @@ func RunMonitorOverview(tokenFlag string) { output.Errorf("cannot parse monitor overview: %v", err) } + online := 0 + for _, s := range o.Servers { + if s.Status == "online" { + online++ + } + } output.PrintKeyValue( - "total-servers", fmt.Sprintf("%d", o.TotalServers), - "online-servers", fmt.Sprintf("%d", o.OnlineServers), + "total-servers", fmt.Sprintf("%d", len(o.Servers)), + "online-servers", fmt.Sprintf("%d", online), + "generated-at", o.GeneratedAt, ) } @@ -73,7 +106,7 @@ func RunMonitorServerList(tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Get("/monitor/servers") + data, err := c.Get("/monitor/admin/servers") if err != nil { output.Errorf("failed to list monitor servers: %v", err) } @@ -116,39 +149,37 @@ func RunMonitorServerGet(identifier, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Get("/monitor/servers/" + identifier) - if err != nil { - output.Errorf("failed to get server: %v", err) + servers := monitorServerList(c) + var found *monitorServerResponse + for i := range servers { + if servers[i].Identifier == identifier { + found = &servers[i] + break + } + } + if found == nil { + output.Errorf("failed to get server: not found: %s", identifier) } if output.JSONMode { - var raw json.RawMessage - if err := json.Unmarshal(data, &raw); err != nil { - output.Errorf("invalid JSON response: %v", err) - } - output.PrintJSON(raw) + output.PrintJSON(found) return } - var s monitorServerResponse - if err := json.Unmarshal(data, &s); err != nil { - output.Errorf("cannot parse server: %v", err) - } - name := "" - if s.DisplayName != nil { - name = *s.DisplayName + if found.DisplayName != nil { + name = *found.DisplayName } lastSeen := "" - if s.LastSeen != nil { - lastSeen = *s.LastSeen + if found.LastSeen != nil { + lastSeen = *found.LastSeen } output.PrintKeyValue( - "identifier", s.Identifier, + "identifier", found.Identifier, "name", name, - "status", s.Status, + "status", found.Status, "last-seen", lastSeen, - "created", s.CreatedAt, + "created", found.CreatedAt, ) } @@ -197,7 +228,7 @@ func RunMonitorServerCreate(args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Post("/monitor/servers", bytes.NewReader(body)) + data, err := c.Post("/monitor/admin/servers", bytes.NewReader(body)) if err != nil { output.Errorf("failed to create server: %v", err) } @@ -223,7 +254,8 @@ func RunMonitorServerDelete(identifier, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - _, err = c.Delete("/monitor/servers/" + identifier) + serverID := resolveMonitorServerID(c, identifier) + _, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d", serverID)) if err != nil { output.Errorf("failed to delete server: %v", err) } @@ -238,7 +270,8 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Post("/monitor/servers/"+identifier+"/api-key", nil) + serverID := resolveMonitorServerID(c, identifier) + data, err := c.Post(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID), nil) if err != nil { output.Errorf("failed to generate API key: %v", err) } @@ -258,8 +291,9 @@ func RunMonitorAPIKeyGenerate(identifier, tokenFlag string) { return } output.PrintKeyValue( - "identifier", k.Identifier, + "server-id", fmt.Sprintf("%d", k.ServerID), "api-key", k.APIKey, + "message", k.Message, ) } @@ -271,7 +305,8 @@ func RunMonitorAPIKeyRevoke(identifier, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - _, err = c.Delete("/monitor/servers/" + identifier + "/api-key") + serverID := resolveMonitorServerID(c, identifier) + _, err = c.Delete(fmt.Sprintf("/monitor/admin/servers/%d/api-key", serverID)) if err != nil { output.Errorf("failed to revoke API key: %v", err) } diff --git a/internal/commands/propose.go b/internal/commands/propose.go index 24bc065..d572aea 100644 --- a/internal/commands/propose.go +++ b/internal/commands/propose.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "net/url" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/config" @@ -23,11 +24,35 @@ type proposeResponse struct { CreatedAt string `json:"created_at"` } +type projectLookup struct { + ID int `json:"id"` + ProjectCode string `json:"project_code"` +} + +func resolveProposalProject(c *client.Client, proposalCode string) string { + data, err := c.Get("/projects") + if err != nil { + output.Errorf("failed to list projects for proposal lookup: %v", err) + } + var projects []projectLookup + if err := json.Unmarshal(data, &projects); err != nil { + output.Errorf("cannot parse project list for proposal lookup: %v", err) + } + for _, p := range projects { + if _, err := c.Get("/projects/" + p.ProjectCode + "/proposals/" + proposalCode); err == nil { + return p.ProjectCode + } + } + output.Errorf("proposal not found: %s", proposalCode) + return "" +} + // RunProposeList implements `hf propose list --project <project-code>`. func RunProposeList(args []string, tokenFlag string) { token := ResolveToken(tokenFlag) - query := "" + project := "" + query := url.Values{} for i := 0; i < len(args); i++ { switch args[i] { case "--project": @@ -35,32 +60,35 @@ func RunProposeList(args []string, tokenFlag string) { output.Error("--project requires a value") } i++ - query = appendQuery(query, "project", args[i]) + project = args[i] case "--status": if i+1 >= len(args) { output.Error("--status requires a value") } i++ - query = appendQuery(query, "status", args[i]) + query.Set("status", args[i]) case "--order-by": if i+1 >= len(args) { output.Error("--order-by requires a value") } i++ - query = appendQuery(query, "order_by", args[i]) + query.Set("order_by", args[i]) default: output.Errorf("unknown flag: %s", args[i]) } } + if project == "" { + output.Error("usage: hf propose list --project <project-code> [--status <status>] [--order-by <field>]") + } cfg, err := config.Load() if err != nil { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - path := "/proposes" - if query != "" { - path += "?" + query + path := "/projects/" + project + "/proposals" + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded } data, err := c.Get(path) if err != nil { @@ -105,7 +133,8 @@ func RunProposeGet(proposeCode, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Get("/proposes/" + proposeCode) + project := resolveProposalProject(c, proposeCode) + data, err := c.Get("/projects/" + project + "/proposals/" + proposeCode) if err != nil { output.Errorf("failed to get proposal: %v", err) } @@ -178,9 +207,8 @@ func RunProposeCreate(args []string, tokenFlag string) { } payload := map[string]interface{}{ - "project_code": project, - "title": title, - "description": desc, + "title": title, + "description": desc, } body, err := json.Marshal(payload) @@ -193,7 +221,7 @@ func RunProposeCreate(args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Post("/proposes", bytes.NewReader(body)) + data, err := c.Post("/projects/"+project+"/proposals", bytes.NewReader(body)) if err != nil { output.Errorf("failed to create proposal: %v", err) } @@ -253,7 +281,8 @@ func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - _, err = c.Patch("/proposes/"+proposeCode, bytes.NewReader(body)) + project := resolveProposalProject(c, proposeCode) + _, err = c.Patch("/projects/"+project+"/proposals/"+proposeCode, bytes.NewReader(body)) if err != nil { output.Errorf("failed to update proposal: %v", err) } @@ -311,7 +340,8 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - data, err := c.Post("/proposes/"+proposeCode+"/accept", bytes.NewReader(body)) + project := resolveProposalProject(c, proposeCode) + data, err := c.Post("/projects/"+project+"/proposals/"+proposeCode+"/accept", bytes.NewReader(body)) if err != nil { output.Errorf("failed to accept proposal: %v", err) } @@ -380,7 +410,8 @@ func RunProposeReject(proposeCode string, args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - _, err = c.Post("/proposes/"+proposeCode+"/reject", body) + project := resolveProposalProject(c, proposeCode) + _, err = c.Post("/projects/"+project+"/proposals/"+proposeCode+"/reject", body) if err != nil { output.Errorf("failed to reject proposal: %v", err) } @@ -397,7 +428,8 @@ func RunProposeReopen(proposeCode, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - _, err = c.Post("/proposes/"+proposeCode+"/reopen", nil) + project := resolveProposalProject(c, proposeCode) + _, err = c.Post("/projects/"+project+"/proposals/"+proposeCode+"/reopen", nil) if err != nil { output.Errorf("failed to reopen proposal: %v", err) } From e2177521e096e2cb0ef7a7286f2ff49f53a020ec Mon Sep 17 00:00:00 2001 From: orion <orion@op.hangman-lab.top> Date: Fri, 3 Apr 2026 16:25:11 +0000 Subject: [PATCH 6/8] feat: switch cli indexing to code-first identifiers --- internal/commands/essential.go | 13 +-- internal/commands/meeting.go | 2 +- internal/commands/milestone.go | 2 +- internal/commands/proposal_test.go | 136 +++++++++++++++++++---------- internal/commands/propose.go | 38 ++++---- internal/commands/task.go | 6 +- 6 files changed, 124 insertions(+), 73 deletions(-) diff --git a/internal/commands/essential.go b/internal/commands/essential.go index f1f6c27..8cf99e0 100644 --- a/internal/commands/essential.go +++ b/internal/commands/essential.go @@ -13,7 +13,6 @@ import ( type essentialResponse struct { ID int `json:"id"` EssentialCode string `json:"essential_code"` - ProposalID int `json:"proposal_id"` Type string `json:"type"` Title string `json:"title"` Description *string `json:"description"` @@ -49,8 +48,7 @@ func RunEssentialList(args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - project := resolveProposalProject(c, proposalCode) - data, err := c.Get("/projects/" + project + "/proposals/" + proposalCode + "/essentials") + data, err := c.Get(proposalPath(c, proposalCode) + "/essentials") if err != nil { output.Errorf("failed to list essentials: %v", err) } @@ -147,8 +145,7 @@ func RunEssentialCreate(args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - project := resolveProposalProject(c, proposalCode) - data, err := c.Post("/projects/"+project+"/proposals/"+proposalCode+"/essentials", bytes.NewReader(body)) + data, err := c.Post(proposalPath(c, proposalCode)+"/essentials", bytes.NewReader(body)) if err != nil { output.Errorf("failed to create essential: %v", err) } @@ -231,8 +228,7 @@ func RunEssentialUpdate(essentialCode string, args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - project := resolveProposalProject(c, proposalCode) - _, err = c.Patch("/projects/"+project+"/proposals/"+proposalCode+"/essentials/"+essentialCode, bytes.NewReader(body)) + _, err = c.Patch(proposalPath(c, proposalCode)+"/essentials/"+essentialCode, bytes.NewReader(body)) if err != nil { output.Errorf("failed to update essential: %v", err) } @@ -269,8 +265,7 @@ func RunEssentialDeleteFull(essentialCode string, args []string, tokenFlag strin output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - project := resolveProposalProject(c, proposalCode) - _, err = c.Delete("/projects/" + project + "/proposals/" + proposalCode + "/essentials/" + essentialCode) + _, err = c.Delete(proposalPath(c, proposalCode) + "/essentials/" + essentialCode) if err != nil { output.Errorf("failed to delete essential: %v", err) } diff --git a/internal/commands/meeting.go b/internal/commands/meeting.go index 0d34285..55f4ad9 100644 --- a/internal/commands/meeting.go +++ b/internal/commands/meeting.go @@ -36,7 +36,7 @@ func RunMeetingList(args []string, tokenFlag string) { output.Error("--project requires a value") } i++ - query = appendQuery(query, "project", args[i]) + query = appendQuery(query, "project_code", args[i]) case "--status": if i+1 >= len(args) { output.Error("--status requires a value") diff --git a/internal/commands/milestone.go b/internal/commands/milestone.go index dea241c..69cbe70 100644 --- a/internal/commands/milestone.go +++ b/internal/commands/milestone.go @@ -44,7 +44,7 @@ func RunMilestoneList(args []string, tokenFlag string) { output.Error("--project requires a value") } i++ - query = appendQuery(query, "project", args[i]) + query = appendQuery(query, "project_code", args[i]) case "--status": if i+1 >= len(args) { output.Error("--status requires a value") diff --git a/internal/commands/proposal_test.go b/internal/commands/proposal_test.go index c0c89a9..db41ce3 100644 --- a/internal/commands/proposal_test.go +++ b/internal/commands/proposal_test.go @@ -170,20 +170,29 @@ func TestEssentialDelete_MissingProposal(t *testing.T) { func TestEssentialList_JSONOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/proposes/PRJ-001/essentials" { + switch { + case r.Method == "GET" && r.URL.Path == "/projects": + w.WriteHeader(200) + json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}}) + case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001": + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"}) + case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials": + w.WriteHeader(200) + json.NewEncoder(w).Encode([]interface{}{ + map[string]interface{}{ + "id": 1, + "essential_code": "ESS-001", + "proposal_id": 1, + "type": "feature", + "title": "Add login", + "created_at": "2026-03-01", + }, + }) + default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(404) } - w.WriteHeader(200) - json.NewEncoder(w).Encode([]interface{}{ - map[string]interface{}{ - "id": 1, - "essential_code": "ESS-001", - "proposal_id": 1, - "type": "feature", - "title": "Add login", - "created_at": "2026-03-01", - }, - }) })) defer server.Close() @@ -204,16 +213,25 @@ func TestEssentialList_JSONOutput(t *testing.T) { func TestEssentialCreate_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" { - t.Errorf("expected POST; got: %s", r.Method) + switch { + case r.Method == "GET" && r.URL.Path == "/projects": + w.WriteHeader(200) + json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}}) + case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001": + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"}) + case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials": + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "id": 1, + "essential_code": "ESS-001", + "title": "Add login", + "type": "feature", + }) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(404) } - w.WriteHeader(200) - json.NewEncoder(w).Encode(map[string]interface{}{ - "id": 1, - "essential_code": "ESS-001", - "title": "Add login", - "type": "feature", - }) })) defer server.Close() @@ -234,11 +252,20 @@ func TestEssentialCreate_Success(t *testing.T) { func TestEssentialUpdate_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "PATCH" { - t.Errorf("expected PATCH; got: %s", r.Method) + switch { + case r.Method == "GET" && r.URL.Path == "/projects": + w.WriteHeader(200) + json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}}) + case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001": + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"}) + case r.Method == "PATCH" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001": + w.WriteHeader(200) + w.Write([]byte(`{}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(404) } - w.WriteHeader(200) - w.Write([]byte(`{}`)) })) defer server.Close() @@ -259,11 +286,20 @@ func TestEssentialUpdate_Success(t *testing.T) { func TestEssentialDelete_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "DELETE" { - t.Errorf("expected DELETE; got: %s", r.Method) + switch { + case r.Method == "GET" && r.URL.Path == "/projects": + w.WriteHeader(200) + json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}}) + case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001": + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"}) + case r.Method == "DELETE" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/essentials/ESS-001": + w.WriteHeader(200) + w.Write([]byte(`{}`)) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(404) } - w.WriteHeader(200) - w.Write([]byte(`{}`)) })) defer server.Close() @@ -306,20 +342,29 @@ func TestProposalAccept_MissingMilestone(t *testing.T) { func TestProposalAccept_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "POST" || r.URL.Path != "/proposes/PRJ-001/accept" { + switch { + case r.Method == "GET" && r.URL.Path == "/projects": + w.WriteHeader(200) + json.NewEncoder(w).Encode([]interface{}{map[string]interface{}{"id": 1, "project_code": "PROJ-001"}}) + case r.Method == "GET" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001": + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{"code": "PRJ-001", "project_code": "PROJ-001"}) + case r.Method == "POST" && r.URL.Path == "/projects/PROJ-001/proposals/PRJ-001/accept": + var body map[string]interface{} + json.NewDecoder(r.Body).Decode(&body) + if body["milestone_code"] != "MS-001" { + t.Errorf("expected milestone_code=MS-001; got: %v", body["milestone_code"]) + } + w.WriteHeader(200) + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": "PRJ-001", + "status": "Accepted", + "tasks": []interface{}{}, + }) + default: t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(404) } - var body map[string]interface{} - json.NewDecoder(r.Body).Decode(&body) - if body["milestone_code"] != "MS-001" { - t.Errorf("expected milestone_code=MS-001; got: %v", body["milestone_code"]) - } - w.WriteHeader(200) - json.NewEncoder(w).Encode(map[string]interface{}{ - "code": "PRJ-001", - "status": "Accepted", - "tasks": []interface{}{}, - }) })) defer server.Close() @@ -421,7 +466,7 @@ func TestTaskCreate_StoryTypeOnlyRestricted(t *testing.T) { func TestProposalList_Success(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || r.URL.Path != "/proposes" { + if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" { t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) } w.WriteHeader(200) @@ -444,7 +489,7 @@ func TestProposalList_Success(t *testing.T) { cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) - out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "list", "--token", "fake") + out, err := runCLIProposal(t, tmpDir, cliPath, "proposal", "list", "--token", "fake", "--project", "PROJ-001") if err != nil { t.Fatalf("unexpected error: %v; out=%s", err, out) } @@ -455,6 +500,9 @@ func TestProposalList_Success(t *testing.T) { func TestProposalList_JSONOutput(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/projects/PROJ-001/proposals" { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + } w.WriteHeader(200) json.NewEncoder(w).Encode([]interface{}{ map[string]interface{}{ @@ -472,7 +520,7 @@ func TestProposalList_JSONOutput(t *testing.T) { cliPath := filepath.Join(tmpDir, "hf") buildCLI(t, cliPath) - out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "list", "--token", "fake") + out, err := runCLIProposal(t, tmpDir, cliPath, "--json", "proposal", "list", "--token", "fake", "--project", "PROJ-001") if err != nil { t.Fatalf("unexpected error: %v; out=%s", err, out) } diff --git a/internal/commands/propose.go b/internal/commands/propose.go index d572aea..93226a5 100644 --- a/internal/commands/propose.go +++ b/internal/commands/propose.go @@ -32,21 +32,30 @@ type projectLookup struct { func resolveProposalProject(c *client.Client, proposalCode string) string { data, err := c.Get("/projects") if err != nil { - output.Errorf("failed to list projects for proposal lookup: %v", err) + return "" } var projects []projectLookup if err := json.Unmarshal(data, &projects); err != nil { - output.Errorf("cannot parse project list for proposal lookup: %v", err) + return "" } for _, p := range projects { + if p.ProjectCode == "" { + continue + } if _, err := c.Get("/projects/" + p.ProjectCode + "/proposals/" + proposalCode); err == nil { return p.ProjectCode } } - output.Errorf("proposal not found: %s", proposalCode) return "" } +func proposalPath(c *client.Client, proposalCode string) string { + if project := resolveProposalProject(c, proposalCode); project != "" { + return "/projects/" + project + "/proposals/" + proposalCode + } + return "/proposes/" + proposalCode +} + // RunProposeList implements `hf propose list --project <project-code>`. func RunProposeList(args []string, tokenFlag string) { token := ResolveToken(tokenFlag) @@ -77,8 +86,9 @@ func RunProposeList(args []string, tokenFlag string) { output.Errorf("unknown flag: %s", args[i]) } } + legacyPath := false if project == "" { - output.Error("usage: hf propose list --project <project-code> [--status <status>] [--order-by <field>]") + legacyPath = true } cfg, err := config.Load() @@ -87,6 +97,9 @@ func RunProposeList(args []string, tokenFlag string) { } c := client.New(cfg.BaseURL, token) path := "/projects/" + project + "/proposals" + if legacyPath { + path = "/proposes" + } if encoded := query.Encode(); encoded != "" { path += "?" + encoded } @@ -133,8 +146,7 @@ func RunProposeGet(proposeCode, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - project := resolveProposalProject(c, proposeCode) - data, err := c.Get("/projects/" + project + "/proposals/" + proposeCode) + data, err := c.Get(proposalPath(c, proposeCode)) if err != nil { output.Errorf("failed to get proposal: %v", err) } @@ -281,8 +293,7 @@ func RunProposeUpdate(proposeCode string, args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - project := resolveProposalProject(c, proposeCode) - _, err = c.Patch("/projects/"+project+"/proposals/"+proposeCode, bytes.NewReader(body)) + _, err = c.Patch(proposalPath(c, proposeCode), bytes.NewReader(body)) if err != nil { output.Errorf("failed to update proposal: %v", err) } @@ -340,8 +351,7 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - project := resolveProposalProject(c, proposeCode) - data, err := c.Post("/projects/"+project+"/proposals/"+proposeCode+"/accept", bytes.NewReader(body)) + data, err := c.Post(proposalPath(c, proposeCode)+"/accept", bytes.NewReader(body)) if err != nil { output.Errorf("failed to accept proposal: %v", err) } @@ -362,7 +372,7 @@ func RunProposeAccept(proposeCode string, args []string, tokenFlag string) { if err := json.Unmarshal(data, &resp); err == nil && len(resp.GeneratedTasks) > 0 { fmt.Printf("\nGenerated %d story task(s):\n", len(resp.GeneratedTasks)) for _, gt := range resp.GeneratedTasks { - code := "" + code := "(no task_code)" if gt.TaskCode != nil { code = *gt.TaskCode } @@ -410,8 +420,7 @@ func RunProposeReject(proposeCode string, args []string, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - project := resolveProposalProject(c, proposeCode) - _, err = c.Post("/projects/"+project+"/proposals/"+proposeCode+"/reject", body) + _, err = c.Post(proposalPath(c, proposeCode)+"/reject", body) if err != nil { output.Errorf("failed to reject proposal: %v", err) } @@ -428,8 +437,7 @@ func RunProposeReopen(proposeCode, tokenFlag string) { output.Errorf("config error: %v", err) } c := client.New(cfg.BaseURL, token) - project := resolveProposalProject(c, proposeCode) - _, err = c.Post("/projects/"+project+"/proposals/"+proposeCode+"/reopen", nil) + _, err = c.Post(proposalPath(c, proposeCode)+"/reopen", nil) if err != nil { output.Errorf("failed to reopen proposal: %v", err) } diff --git a/internal/commands/task.go b/internal/commands/task.go index be2c30d..7f6a5ab 100644 --- a/internal/commands/task.go +++ b/internal/commands/task.go @@ -38,13 +38,13 @@ func RunTaskList(args []string, tokenFlag string) { output.Error("--project requires a value") } i++ - query = appendQuery(query, "project", args[i]) + query = appendQuery(query, "project_code", args[i]) case "--milestone": if i+1 >= len(args) { output.Error("--milestone requires a value") } i++ - query = appendQuery(query, "milestone", args[i]) + query = appendQuery(query, "milestone_code", args[i]) case "--status": if i+1 >= len(args) { output.Error("--status requires a value") @@ -426,7 +426,7 @@ func RunTaskSearch(args []string, tokenFlag string) { output.Error("--project requires a value") } i++ - query = appendQuery(query, "project", args[i]) + query = appendQuery(query, "project_code", args[i]) case "--status": if i+1 >= len(args) { output.Error("--status requires a value") From ad0e123666ebb34453a62a8aa4c64cb01f851d6d Mon Sep 17 00:00:00 2001 From: orion <orion@op.hangman-lab.top> Date: Fri, 3 Apr 2026 19:12:34 +0000 Subject: [PATCH 7/8] fix: send account-manager token as x-api-key --- internal/client/client.go | 16 +++++++++++++++- internal/commands/user.go | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/internal/client/client.go b/internal/client/client.go index 1606a03..9fce9f5 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -14,6 +14,7 @@ import ( type Client struct { BaseURL string Token string + APIKey string HTTPClient *http.Client } @@ -28,6 +29,17 @@ func New(baseURL, token string) *Client { } } +// NewWithAPIKey creates a Client that authenticates using X-API-Key. +func NewWithAPIKey(baseURL, apiKey string) *Client { + return &Client{ + BaseURL: strings.TrimRight(baseURL, "/"), + APIKey: apiKey, + HTTPClient: &http.Client{ + Timeout: 30 * time.Second, + }, + } +} + // RequestError represents a non-2xx HTTP response. type RequestError struct { StatusCode int @@ -45,7 +57,9 @@ func (c *Client) Do(method, path string, body io.Reader) ([]byte, error) { if err != nil { return nil, fmt.Errorf("cannot create request: %w", err) } - if c.Token != "" { + if c.APIKey != "" { + req.Header.Set("X-API-Key", c.APIKey) + } else if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } if body != nil { diff --git a/internal/commands/user.go b/internal/commands/user.go index 445b1ac..bdbd133 100644 --- a/internal/commands/user.go +++ b/internal/commands/user.go @@ -194,7 +194,7 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string) if err != nil { output.Errorf("config error: %v", err) } - c := client.New(cfg.BaseURL, accMgrToken) + c := client.NewWithAPIKey(cfg.BaseURL, accMgrToken) data, err := c.Post("/users", bytes.NewReader(body)) if err != nil { output.Errorf("failed to create user: %v", err) From 5ac90408f331dbf6f9c6679cc9095b7c627c3dfc Mon Sep 17 00:00:00 2001 From: orion <orion@op.hangman-lab.top> Date: Sat, 4 Apr 2026 20:16:59 +0000 Subject: [PATCH 8/8] feat: support discord id account updates --- cmd/hf/main.go | 56 ++++++++++++++++++++++++++++++++-- internal/commands/user.go | 63 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 3 deletions(-) diff --git a/cmd/hf/main.go b/cmd/hf/main.go index 7610a8f..cbea4e5 100644 --- a/cmd/hf/main.go +++ b/cmd/hf/main.go @@ -32,6 +32,28 @@ func main() { handleLeafOrRun("health", args[1:], commands.RunHealth) case "config": handleConfig(args[1:]) + case "update-discord-id": + tokenFlag := "" + var filtered []string + for i := 1; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + if len(filtered) < 1 { + output.Error("usage: hf update-discord-id <username> [discord-id]") + } + discordID := "" + if len(filtered) >= 2 { + discordID = filtered[1] + } + commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag) default: if group, ok := findGroup(args[0]); ok { handleGroup(group, args[1:]) @@ -204,6 +226,31 @@ func handleGroup(group help.Group, args []string) { return } + if len(args) > 0 && args[0] == "update-discord-id" { + tokenFlag := "" + var filtered []string + for i := 1; i < len(args); i++ { + switch args[i] { + case "--token": + if i+1 < len(args) { + i++ + tokenFlag = args[i] + } + default: + filtered = append(filtered, args[i]) + } + } + if len(filtered) < 1 { + output.Error("usage: hf update-discord-id <username> [discord-id]") + } + discordID := "" + if len(filtered) >= 2 { + discordID = filtered[1] + } + commands.RunUserUpdateDiscordID(filtered[0], discordID, tokenFlag) + return + } + output.Errorf("hf %s %s is recognized but not implemented yet", group.Name, sub.Name) } @@ -238,7 +285,7 @@ func handleUserCommand(subCmd string, args []string) { } commands.RunUserGet(filtered[0], tokenFlag) case "create": - username, password, email, fullName := "", "", "", "" + username, password, email, fullName, discordUserID := "", "", "", "", "" for i := 0; i < len(filtered); i++ { switch filtered[i] { case "--user": @@ -261,6 +308,11 @@ func handleUserCommand(subCmd string, args []string) { i++ fullName = filtered[i] } + case "--discord-user-id": + if i+1 < len(filtered) { + i++ + discordUserID = filtered[i] + } default: output.Errorf("unknown flag: %s", filtered[i]) } @@ -268,7 +320,7 @@ func handleUserCommand(subCmd string, args []string) { if username == "" { output.Error("usage: hf user create --user <username>") } - commands.RunUserCreate(username, password, email, fullName, accMgrTokenFlag) + commands.RunUserCreate(username, password, email, fullName, discordUserID, accMgrTokenFlag) case "update": if len(filtered) < 1 { output.Error("usage: hf user update <username> [--email ...] [--full-name ...] [--pass ...] [--active ...]") diff --git a/internal/commands/user.go b/internal/commands/user.go index bdbd133..8381a5d 100644 --- a/internal/commands/user.go +++ b/internal/commands/user.go @@ -4,6 +4,8 @@ import ( "bytes" "encoding/json" "fmt" + "os" + "os/exec" "strings" "git.hangman-lab.top/zhi/HarborForge.Cli/internal/client" @@ -23,6 +25,7 @@ type userResponse struct { IsAdmin bool `json:"is_admin"` RoleID *int `json:"role_id"` RoleName *string `json:"role_name"` + DiscordUserID *string `json:"discord_user_id"` CreatedAt string `json:"created_at"` } @@ -137,10 +140,41 @@ type userCreatePayload struct { Email string `json:"email"` FullName *string `json:"full_name,omitempty"` Password *string `json:"password,omitempty"` + DiscordUserID *string `json:"discord_user_id,omitempty"` +} + +func maybeResolveDiscordUserID(explicit string, requireEnv bool) (string, bool, error) { + if strings.TrimSpace(explicit) != "" { + return strings.TrimSpace(explicit), true, nil + } + agentID := strings.TrimSpace(os.Getenv("AGENT_ID")) + agentVerify := strings.TrimSpace(os.Getenv("AGENT_VERIFY")) + if agentID == "" || agentVerify == "" { + if requireEnv { + return "", false, fmt.Errorf("discord id not provided and AGENT_ID/AGENT_VERIFY are missing") + } + return "", false, nil + } + cmd := exec.Command("ego-mgr", "get", "discord-id") + out, err := cmd.Output() + if err != nil { + if requireEnv { + return "", false, fmt.Errorf("failed to resolve discord id from ego-mgr: %w", err) + } + return "", false, nil + } + value := strings.TrimSpace(string(out)) + if value == "" { + if requireEnv { + return "", false, fmt.Errorf("ego-mgr returned empty discord id") + } + return "", false, nil + } + return value, true, nil } // RunUserCreate implements `hf user create`. -func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string) { +func RunUserCreate(username, password, email, fullName, discordUserID, accMgrTokenFlag string) { // Resolve account-manager token var accMgrToken string if mode.IsPaddedCell() { @@ -181,6 +215,11 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string) Email: email, Password: &password, } + if resolvedDiscordID, ok, err := maybeResolveDiscordUserID(discordUserID, false); err != nil { + output.Errorf("failed to resolve discord user id: %v", err) + } else if ok { + payload.DiscordUserID = &resolvedDiscordID + } if fullName != "" { payload.FullName = &fullName } @@ -216,6 +255,28 @@ func RunUserCreate(username, password, email, fullName, accMgrTokenFlag string) fmt.Printf("user created: %s\n", u.Username) } +// RunUserUpdateDiscordID updates a user's discord_user_id field. +func RunUserUpdateDiscordID(username, discordUserID, tokenFlag string) { + token := ResolveToken(tokenFlag) + resolvedDiscordID, _, err := maybeResolveDiscordUserID(discordUserID, true) + if err != nil { + output.Errorf("failed to resolve discord user id: %v", err) + } + body, err := json.Marshal(map[string]interface{}{"discord_user_id": resolvedDiscordID}) + if err != nil { + output.Errorf("cannot marshal payload: %v", err) + } + cfg, err := config.Load() + if err != nil { + output.Errorf("config error: %v", err) + } + c := client.New(cfg.BaseURL, token) + if _, err := c.Patch("/users/"+username, bytes.NewReader(body)); err != nil { + output.Errorf("failed to update discord id: %v", err) + } + fmt.Printf("discord id updated: %s\n", username) +} + // RunUserUpdate implements `hf user update <username>`. func RunUserUpdate(username string, args []string, tokenFlag string) { token := ResolveToken(tokenFlag)