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")}, }, }, {